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

Freegle / iznik-nuxt3 / 99e3f760-c611-4bfd-88c0-b43b43faaba6

05 Dec 2025 11:02PM UTC coverage: 42.545% (+0.4%) from 42.1%
99e3f760-c611-4bfd-88c0-b43b43faaba6

push

circleci

actions-user
Auto-merge production to app-ci-fd (daily scheduled)

Automated merge from production branch after successful tests.

🤖 Automated by GitHub Actions

1890 of 4798 branches covered (39.39%)

Branch coverage included in aggregate %.

1 of 3 new or added lines in 1 file covered. (33.33%)

305 existing lines in 20 files now uncovered.

2376 of 5229 relevant lines covered (45.44%)

31.51 hits per line

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

0.0
/components/MessageExpanded.vue
1
<template>
2
  <div
3
    v-if="message"
4
    class="message-expanded-wrapper"
×
5
    :class="{ 'in-modal': inModal }"
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 -->
13
      <Teleport to="#navbar-mobile">
14
        <div class="hidden-navbar" />
15
      </Teleport>
16

17
      <!-- Photo Area with Ken Burns animation -->
18
      <div class="photo-area" @click="showPhotosModal">
19
        <!-- Back button on photo -->
20
        <button class="back-button" @click.stop="goBack">
21
          <v-icon icon="arrow-left" />
22
        </button>
23

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

87
        <!-- Actual photo or placeholder -->
88
        <div
89
          v-if="gotAttachments"
90
          class="photo-container"
91
          :class="{ 'ken-burns': !prefersReducedMotion }"
92
        >
93
          <OurUploadedImage
94
            v-if="currentAttachment?.ouruid"
95
            :src="currentAttachment.ouruid"
96
            :modifiers="currentAttachment.externalmods"
UNCOV
97
            alt="Item Photo"
×
98
            class="photo-image"
99
            :width="640"
100
            :height="480"
UNCOV
101
          />
×
102
          <NuxtPicture
103
            v-else-if="currentAttachment?.externaluid"
104
            format="webp"
105
            provider="uploadcare"
106
            :src="currentAttachment.externaluid"
107
            :modifiers="currentAttachment.externalmods"
108
            alt="Item Photo"
109
            class="photo-image"
110
            :width="640"
111
            :height="480"
112
          />
113
          <ProxyImage
114
            v-else-if="currentAttachment?.path"
115
            class-name="photo-image"
116
            alt="Item picture"
117
            :src="currentAttachment.path"
118
            :width="640"
119
            :height="480"
120
            fit="cover"
121
          />
122
        </div>
123

124
        <!-- No photo - show placeholder -->
125
        <div v-else class="photo-container">
126
          <MessagePhotoPlaceholder
127
            :placeholder-class="placeholderClass"
128
            :icon="categoryIcon"
129
          />
130
        </div>
131

132
        <!-- Poster overlay on photo (shown on shorter screens) -->
133
        <NuxtLink
134
          v-if="poster"
135
          :to="posterProfileUrl"
136
          class="poster-overlay"
137
          :class="{ 'poster-overlay--below-carousel': attachmentCount > 1 }"
138
          @click.stop
139
        >
140
          <div class="poster-overlay-avatar-wrapper">
141
            <ProfileImage
142
              :image="poster.profile?.paththumb"
143
              :externaluid="poster.profile?.externaluid"
144
              :ouruid="poster.profile?.ouruid"
145
              :externalmods="poster.profile?.externalmods"
146
              :name="poster.displayname"
UNCOV
147
              class="poster-overlay-avatar"
×
148
              is-thumbnail
UNCOV
149
              size="sm"
×
UNCOV
150
            />
×
UNCOV
151
            <div v-if="poster.supporter" class="supporter-badge-small">
×
152
              <v-icon icon="trophy" />
153
            </div>
UNCOV
154
          </div>
×
UNCOV
155
          <div class="poster-overlay-info">
×
UNCOV
156
            <span class="poster-overlay-name">{{ poster.displayname }}</span>
×
157
            <div class="poster-overlay-stats">
158
              <span v-if="poster.info?.offers" class="poster-overlay-stat">
UNCOV
159
                <v-icon icon="gift" />{{ poster.info.offers }}
×
UNCOV
160
              </span>
×
161
              <span v-if="poster.info?.wanteds" class="poster-overlay-stat">
162
                <v-icon icon="search" />{{ poster.info.wanteds }}
UNCOV
163
              </span>
×
UNCOV
164
            </div>
×
165
          </div>
166
          <v-icon icon="chevron-right" class="poster-overlay-chevron" />
UNCOV
167
        </NuxtLink>
×
UNCOV
168

×
169
        <!-- Title overlay at bottom of photo - matches summary layout -->
×
170
        <div class="title-overlay">
×
171
          <div class="info-row">
172
            <MessageTag :id="id" :inline="true" class="title-tag ps-1 pe-1" />
173
            <div class="info-icons">
174
              <span
UNCOV
175
                v-if="distanceText"
×
UNCOV
176
                class="location"
×
177
                @click.stop="showMapModal = true"
178
              >
UNCOV
179
                <v-icon icon="map-marker-alt" />{{ distanceText }}
×
UNCOV
180
              </span>
×
181
              <span
UNCOV
182
                v-b-tooltip.click.blur="{
×
UNCOV
183
                  title: fullTimeAgo,
×
184
                  customClass: 'mobile-tooltip',
185
                }"
186
                class="time"
187
                @click.stop
188
              >
UNCOV
189
                <v-icon icon="clock" />{{ timeAgo }}
×
190
              </span>
191
              <span
192
                v-b-tooltip.click.blur="{
UNCOV
193
                  title: replyTooltip,
×
194
                  customClass: 'mobile-tooltip',
195
                }"
196
                class="replies"
UNCOV
197
                @click.stop
×
UNCOV
198
              >
×
199
                <v-icon icon="comments" />{{ replyCount }}
200
              </span>
201
              <span
202
                v-if="message.deliverypossible && isOffer"
203
                v-b-tooltip.click.blur="{
204
                  title: `Delivery may be possible - you can ask, but don't assume it will be`,
UNCOV
205
                  customClass: 'mobile-tooltip',
×
UNCOV
206
                }"
×
UNCOV
207
                class="delivery"
×
208
                @click.stop
209
              >
210
                <v-icon icon="truck" />?
×
211
              </span>
×
212
              <span
×
213
                v-if="message.deadline"
214
                v-b-tooltip.click.blur="{
215
                  title: deadlineTooltip,
216
                  customClass: 'mobile-tooltip',
217
                }"
218
                class="deadline"
219
                @click.stop
220
              >
221
                <v-icon icon="hourglass-end" />Ends {{ formattedDeadline }}
222
              </span>
223
            </div>
224
          </div>
225
          <div class="title-row">
226
            <span class="title-subject">{{ subjectItemName }}</span>
227
          </div>
228
          <div v-if="subjectLocation" class="title-location">
229
            {{ subjectLocation }}
230
          </div>
231
        </div>
232
      </div>
233

234
      <!-- Info Section -->
235
      <div class="info-section">
236
        <!-- Description -->
237
        <div class="description-section">
238
          <div class="section-header">
239
            <span class="section-header-text">DESCRIPTION</span>
240
            <NuxtLink
241
              :to="'/message/' + id"
242
              class="section-id-link"
243
              @click.stop
244
            >
245
              #{{ id }}
246
            </NuxtLink>
247
          </div>
248
          <div class="description-content">
249
            <MessageTextBody :id="id" />
250
          </div>
251
        </div>
252

253
        <!-- Posted by divider and section (shown on taller screens, after description) -->
254
        <div v-if="poster" class="section-header section-header--poster">
255
          <span class="section-header-text">POSTED BY</span>
256
          <NuxtLink :to="posterProfileUrl" class="section-id-link" @click.stop>
257
            #{{ poster.id }}
258
          </NuxtLink>
259
        </div>
260
        <NuxtLink
261
          v-if="poster"
262
          :to="posterProfileUrl"
263
          class="poster-section-wrapper"
264
          @click.stop
265
        >
266
          <div class="poster-avatar-wrapper">
267
            <ProfileImage
268
              :image="poster.profile?.paththumb"
269
              :externaluid="poster.profile?.externaluid"
270
              :ouruid="poster.profile?.ouruid"
271
              :externalmods="poster.profile?.externalmods"
272
              :name="poster.displayname"
273
              class="poster-avatar"
274
              is-thumbnail
275
              size="lg"
276
            />
277
            <div v-if="poster.supporter" class="supporter-badge">
278
              <v-icon icon="trophy" />
279
            </div>
280
          </div>
281
          <div class="poster-details">
282
            <span class="poster-name">{{ poster.displayname }}</span>
283
            <div class="poster-stats">
284
              <span v-if="poster.info?.offers" class="poster-stat">
285
                <v-icon icon="gift" />{{ poster.info.offers
286
                }}<span class="poster-stat-label">OFFERs</span>
287
              </span>
288
              <span v-if="poster.info?.wanteds" class="poster-stat">
289
                <v-icon icon="search" />{{ poster.info.wanteds
290
                }}<span class="poster-stat-label">WANTEDs</span>
291
              </span>
292
              <span v-if="poster.info?.replies" class="poster-stat">
293
                <v-icon icon="envelope" />{{ poster.info.replies
294
                }}<span class="poster-stat-label">replies</span>
295
              </span>
296
            </div>
297
            <div v-if="posterAboutMe" class="poster-aboutme">
298
              {{ posterAboutMe }}
299
            </div>
300
          </div>
301
          <UserRatings
302
            v-if="poster.id"
303
            :id="poster.id"
304
            size="md"
305
            :disabled="fromme"
306
            class="poster-ratings"
307
            @click.stop.prevent
308
          />
309
          <v-icon icon="chevron-right" class="poster-chevron" />
310
        </NuxtLink>
311
      </div>
312
    </div>
313

314
    <!-- Fixed footer with Reply button - outside scrollable container for Safari compatibility -->
315
    <div
316
      class="app-footer"
317
      :class="{ expanded: replyExpanded, stickyAdRendered }"
318
    >
319
      <div v-if="!replyExpanded" class="w-100">
320
        <!-- Promised notice -->
321
        <div
322
          v-if="message.promised && !message.successful && replyable && !fromme"
323
          class="promised-notice mb-2"
324
        >
325
          <v-icon icon="handshake" />
326
          {{ message.promisedtome ? 'Promised to you' : 'Already promised' }}
327
        </div>
328
        <div
329
          v-if="replyable && !replied && !message.successful"
330
          class="footer-buttons"
331
        >
332
          <b-button
333
            variant="secondary"
334
            size="lg"
335
            class="cancel-button"
336
            @click="goBack"
337
          >
338
            Cancel
339
          </b-button>
340
          <b-button
341
            v-if="!fromme"
342
            variant="primary"
343
            size="lg"
344
            class="reply-button"
345
            @click="expandReply"
346
          >
347
            Reply
348
          </b-button>
349
        </div>
350
        <b-alert
351
          v-else-if="replied"
352
          variant="info"
353
          :model-value="true"
354
          class="mb-0"
355
        >
356
          Message sent! Check your <nuxt-link to="/chats">Chats</nuxt-link>.
357
        </b-alert>
358
      </div>
359

360
      <!-- Expanded reply section -->
361
      <div v-else class="reply-expanded-section">
362
        <NoticeMessage
363
          v-if="message.promised && !message.promisedtome"
364
          variant="warning"
365
          class="mb-2"
366
        >
367
          Already promised - you might not get it.
368
        </NoticeMessage>
369
        <client-only>
370
          <MessageReplySection
371
            :id="id"
372
            @close="replyExpanded = false"
373
            @sent="sent"
374
          />
375
        </client-only>
376
      </div>
377
    </div>
378

379
    <!-- Map Modal - Full Screen -->
380
    <Teleport v-if="showMapModal" to="body">
381
      <div class="fullscreen-map-viewer">
382
        <button class="map-back-button" @click="showMapModal = false">
383
          <v-icon icon="arrow-left" />
384
        </button>
385
        <client-only>
386
          <MessageMap
387
            v-if="validPosition"
388
            :home="home"
389
            :position="{ lat: message.lat, lng: message.lng }"
390
            class="fullscreen-map"
391
          />
392
        </client-only>
393
        <div class="map-hint">
394
          <v-icon icon="info-circle" /> Approximate location shown
395
        </div>
396
      </div>
397
    </Teleport>
398

399
    <!-- Photos Modal -->
400
    <MessagePhotosModal
401
      v-if="showMessagePhotosModal && attachmentCount"
402
      :id="message.id"
403
      @hidden="showMessagePhotosModal = false"
404
    />
405
  </div>
406
</template>
407

408
<script setup>
409
import {
410
  ref,
411
  computed,
412
  defineAsyncComponent,
413
  onMounted,
414
  onUnmounted,
415
} from 'vue'
416
import { useRouter } from 'vue-router'
417
import { useMiscStore } from '~/stores/misc'
418
import { useMe } from '~/composables/useMe'
419
import { useMessageDisplay } from '~/composables/useMessageDisplay'
420
import MessageTextBody from '~/components/MessageTextBody'
421
import MessageTag from '~/components/MessageTag'
422
import NoticeMessage from '~/components/NoticeMessage'
423
import MessageReplySection from '~/components/MessageReplySection'
424
import ProfileImage from '~/components/ProfileImage'
425
import UserRatings from '~/components/UserRatings'
426
import { useModalHistory } from '~/composables/useModalHistory'
427

428
const MessageMap = defineAsyncComponent(() => import('~/components/MessageMap'))
429
const MessagePhotosModal = defineAsyncComponent(() =>
430
  import('~/components/MessagePhotosModal')
431
)
432

433
const props = defineProps({
434
  id: {
435
    type: Number,
436
    required: true,
437
  },
438
  replyable: {
439
    type: Boolean,
440
    default: true,
441
  },
442
  hideClose: {
443
    type: Boolean,
444
    default: false,
445
  },
446
  actions: {
447
    type: Boolean,
448
    default: true,
449
  },
450
  isModal: {
451
    type: Boolean,
452
    default: false,
453
  },
454
  inModal: {
455
    type: Boolean,
456
    default: false,
457
  },
458
})
459

460
const emit = defineEmits(['zoom', 'close'])
461

462
const router = useRouter()
463
const miscStore = useMiscStore()
464
const { me } = useMe()
465

466
// Use shared composable for common message display logic
467
const {
468
  message,
469
  subjectItemName,
470
  subjectLocation,
471
  fromme,
472
  gotAttachments,
473
  attachmentCount,
474
  timeAgo,
475
  fullTimeAgo,
476
  distanceText,
477
  replyCount,
478
  replyTooltip,
479
  isOffer,
480
  formattedDeadline,
481
  deadlineTooltip,
482
  successfulText,
483
  placeholderClass,
484
  categoryIcon,
485
  poster,
486
  posterProfileUrl,
487
} = useMessageDisplay(props.id)
488

489
const stickyAdRendered = computed(() => miscStore.stickyAdRendered)
490

491
// State
492
const replied = ref(false)
493
const replyExpanded = ref(false)
494
const showMapModal = ref(false)
495
const showMessagePhotosModal = ref(false)
496
const currentPhotoIndex = ref(0)
497
const containerRef = ref(null)
498
const thumbnailsRef = ref(null)
499
const thumbnailTouchStartX = ref(0)
500
const thumbnailScrollStart = ref(0)
501
let thumbnailScrollInterval = null
502

503
// Computed (additional to composable)
504
const currentAttachment = computed(() => {
505
  return message.value?.attachments?.[currentPhotoIndex.value]
506
})
507

508
const validPosition = computed(() => {
509
  return message.value?.lat || message.value?.lng
510
})
511

512
const home = computed(() => {
513
  if (me.value?.lat || me.value?.lng) {
514
    return { lat: me.value.lat, lng: me.value.lng }
515
  }
516
  return null
517
})
518

519
const prefersReducedMotion = computed(() => {
520
  if (typeof window === 'undefined') return false
521
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
522
})
523

524
const posterAboutMe = computed(() => {
525
  const text = poster.value?.aboutme?.text
526
  if (!text) return null
527
  return text
528
})
529

530
// Methods
531
function goBack() {
532
  if (props.isModal) {
533
    // When used as a modal overlay, emit close to parent
534
    emit('close')
535
  } else {
536
    // When used as a standalone page, navigate back
537
    router.back()
538
  }
539
}
540

541
function showPhotosModal() {
542
  if (gotAttachments.value) {
543
    showMessagePhotosModal.value = true
544
    emit('zoom')
545
  }
546
}
547

548
function selectPhoto(index) {
549
  currentPhotoIndex.value = index
550
}
551

552
function handleThumbnailClick(index) {
553
  if (index === currentPhotoIndex.value) {
554
    // Clicking the already selected thumbnail opens the photo viewer
555
    showPhotosModal()
556
  } else {
557
    selectPhoto(index)
558
  }
559
}
560

561
function onThumbnailTouchStart(e) {
562
  thumbnailTouchStartX.value = e.touches[0].clientX
563
  thumbnailScrollStart.value = thumbnailsRef.value?.scrollLeft || 0
564
}
565

566
function onThumbnailTouchMove(e) {
567
  if (!thumbnailsRef.value) return
568
  const deltaX = thumbnailTouchStartX.value - e.touches[0].clientX
569
  thumbnailsRef.value.scrollLeft = thumbnailScrollStart.value + deltaX
570
}
571

572
function onThumbnailTouchEnd() {
573
  // Swipe complete, scroll position is already set
574
}
575

576
function startThumbnailAutoScroll() {
577
  if (!thumbnailsRef.value || attachmentCount.value <= 1) return
578

579
  // Wait a moment then scroll right slowly, then back
580
  setTimeout(() => {
581
    if (!thumbnailsRef.value) return
582
    const maxScroll =
583
      thumbnailsRef.value.scrollWidth - thumbnailsRef.value.clientWidth
584
    if (maxScroll <= 0) return
585

586
    // Animate scroll manually for smoother control
587
    const duration = 2000 // 2 seconds to scroll
588
    const startTime = performance.now()
589
    const startPos = 0
590

591
    function animateScroll(currentTime) {
592
      if (!thumbnailsRef.value) return
593
      const elapsed = currentTime - startTime
594
      const progress = Math.min(elapsed / duration, 1)
595
      // Ease in-out
596
      const eased =
597
        progress < 0.5
598
          ? 2 * progress * progress
599
          : 1 - Math.pow(-2 * progress + 2, 2) / 2
600
      thumbnailsRef.value.scrollLeft = startPos + maxScroll * eased
601

602
      if (progress < 1) {
603
        requestAnimationFrame(animateScroll)
604
      } else {
605
        // Pause then scroll back
606
        setTimeout(() => {
607
          if (!thumbnailsRef.value) return
608
          const backStartTime = performance.now()
609
          function animateBack(currentTime) {
610
            if (!thumbnailsRef.value) return
611
            const elapsed = currentTime - backStartTime
612
            const progress = Math.min(elapsed / duration, 1)
613
            const eased =
614
              progress < 0.5
615
                ? 2 * progress * progress
616
                : 1 - Math.pow(-2 * progress + 2, 2) / 2
617
            thumbnailsRef.value.scrollLeft = maxScroll - maxScroll * eased
618
            if (progress < 1) {
619
              requestAnimationFrame(animateBack)
620
            }
621
          }
622
          requestAnimationFrame(animateBack)
623
        }, 1000)
624
      }
625
    }
626
    requestAnimationFrame(animateScroll)
627
  }, 1000)
628
}
629

630
function stopThumbnailAutoScroll() {
631
  if (thumbnailScrollInterval) {
632
    clearInterval(thumbnailScrollInterval)
633
    thumbnailScrollInterval = null
634
  }
635
}
636

637
function expandReply() {
638
  console.log(
639
    'DEBUG expandReply called, replyable:',
640
    props.replyable,
641
    'replied:',
642
    replied.value,
643
    'fromme:',
644
    fromme.value
645
  )
646
  replyExpanded.value = true
647
}
648

649
function sent() {
650
  replyExpanded.value = false
651
  replied.value = true
652
  // When used as modal, close after a brief delay so user sees confirmation
653
  if (props.isModal) {
654
    setTimeout(() => {
655
      emit('close')
656
    }, 1500)
657
  }
658
}
659

660
// Handle browser back button/swipe when used as modal
661
useModalHistory(`message-${props.id}`, () => emit('close'), props.isModal)
662

663
onMounted(() => {
664
  // Start auto-scroll hint for thumbnail carousel
665
  startThumbnailAutoScroll()
666
})
667

668
onUnmounted(() => {
669
  stopThumbnailAutoScroll()
670
})
671
</script>
672

673
<style scoped lang="scss">
674
@import 'bootstrap/scss/functions';
675
@import 'bootstrap/scss/variables';
676
@import 'bootstrap/scss/mixins/_breakpoints';
677
@import 'assets/css/sticky-banner.scss';
678
@import 'assets/css/_color-vars.scss';
679

680
.message-expanded-wrapper {
681
  position: fixed;
682
  top: 0;
683
  left: 0;
684
  right: 0;
685
  bottom: 0;
686
  z-index: 1000;
687

688
  /* When inside a b-modal, disable fixed positioning */
689
  &.in-modal {
690
    position: relative;
691
    top: auto;
692
    left: auto;
693
    right: auto;
694
    bottom: auto;
695
    z-index: auto;
696
  }
697
}
698

699
.message-expanded-mobile {
700
  display: flex;
701
  flex-direction: column;
702
  position: fixed;
703
  top: 0;
704
  left: 0;
705
  right: 0;
706
  bottom: 80px;
707
  background: $color-white;
708
  overflow-y: auto;
709
  overflow-x: hidden;
710
  z-index: 1000;
711
  scrollbar-width: none;
712
  -ms-overflow-style: none;
713

714
  &::-webkit-scrollbar {
715
    display: none;
716
  }
717

718
  &.stickyAdRendered {
719
    bottom: calc(80px + $sticky-banner-height-mobile);
720

721
    @media (min-height: $mobile-tall) {
722
      bottom: calc(80px + $sticky-banner-height-mobile-tall);
723
    }
724
  }
725

726
  /* When inside a b-modal, disable fixed positioning */
727
  .in-modal & {
728
    position: relative;
729
    top: auto;
730
    left: auto;
731
    right: auto;
732
    bottom: auto;
733
    z-index: auto;
734
    max-height: 70vh;
735

736
    &.stickyAdRendered {
737
      bottom: auto;
738
    }
739
  }
740
}
741

742
// Photo Area - fixed height, scrollable with content
743
.photo-area {
744
  position: relative;
745
  width: 100%;
746
  flex: 0 0 auto;
747
  height: 50vh;
748
  min-height: 200px;
749
  overflow: hidden;
750
  background: $color-gray--lighter;
751
  cursor: pointer;
752
}
753

754
// Photo container - positioned to fill photo-area
755
.photo-container {
756
  width: 100%;
757
  height: 100%;
758
  position: absolute;
759
  top: 0;
760
  left: 0;
761
  overflow: hidden;
762
}
763

764
// All image elements fill container
765
.photo-container :deep(picture),
766
.photo-container :deep(img) {
767
  width: 100%;
768
  height: 100%;
769
  object-fit: cover;
770
  display: block;
771
}
772

773
// Ken Burns animation is in unscoped style block at end of file
774

775
// Stats pills inside title overlay
776
.stats-pills {
777
  display: flex;
778
  justify-content: space-between;
779
  align-items: center;
780
  width: 100%;
781
  margin-top: 0.5rem;
782
}
783

784
.pills-left {
785
  display: flex;
786
  flex-wrap: wrap;
787
  gap: 0.25rem;
788
}
789

790
.pills-right {
791
  display: flex;
792
  flex-wrap: wrap;
793
  gap: 0.25rem;
794
}
795

796
.stat-pill {
797
  display: inline-flex;
798
  align-items: center;
799
  gap: 0.15rem;
800
  background: $color-white-opacity-25;
801
  color: $color-white;
802
  padding: 0.15rem 0.4rem;
803
  border-radius: 1rem;
804
  font-size: 0.7rem;
805

806
  &.clickable {
807
    cursor: pointer;
808
    background: $color-blue--bright;
809
  }
810
}
811

812
.delivery-maybe {
813
  font-weight: bold;
814
  font-size: 0.8rem;
815
  margin-left: -0.1rem;
816
}
817

818
// Status overlay image (promised/freegled)
819
.status-overlay-image {
820
  position: absolute;
821
  z-index: 10;
822
  transform: rotate(15deg);
823
  top: 50%;
824
  left: 50%;
825
  width: 50%;
826
  max-width: 200px;
827
  margin-left: -25%;
828
  margin-top: -15%;
829
  pointer-events: none;
830
}
831

832
// Thumbnail carousel at top of photo area
833
.thumbnail-carousel {
834
  position: absolute;
835
  top: 1rem; // Same as back button
836
  transform: translateY(
837
    calc((40px - 50px) / 2)
838
  ); // Center 50px thumbnails with 40px button
839
  left: 50px; // Closer to back button, fade handles overlap
840
  right: 1rem;
841
  z-index: 11;
842
  display: flex;
843
  gap: 8px;
844
  overflow-x: auto;
845
  scrollbar-width: none;
846
  -ms-overflow-style: none;
847
  padding: 4px;
848
  padding-left: 15px;
849
  mask-image: linear-gradient(to right, transparent 0%, black 15px);
850
  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 15px);
851

852
  &::-webkit-scrollbar {
853
    display: none;
854
  }
855
}
856

857
.thumbnail-item {
858
  flex-shrink: 0;
859
  width: 50px;
860
  height: 50px;
861
  border-radius: 8px;
862
  overflow: hidden;
863
  border: 2px solid $color-white-opacity-50;
864
  cursor: pointer;
865
  transition: border-color 0.2s, transform 0.2s;
866

867
  &.active {
868
    border-color: $color-white;
869
    transform: scale(1.1);
870
  }
871

872
  &:not(.active):hover {
873
    border-color: $color-white-opacity-80;
874
  }
875
}
876

877
.thumbnail-image {
878
  width: 100%;
879
  height: 100%;
880
  object-fit: cover;
881
}
882

883
// Back button on photo
884
.back-button {
885
  position: absolute;
886
  top: 1rem;
887
  left: 1rem;
888
  width: 40px;
889
  height: 40px;
890
  border-radius: 50%;
891
  background: $color-black-opacity-50;
892
  border: none;
893
  color: $color-white;
894
  display: flex;
895
  align-items: center;
896
  justify-content: center;
897
  cursor: pointer;
898
  z-index: 12;
899
  font-size: 1.2rem;
900

901
  &:hover {
902
    background: $color-black-opacity-70;
903
  }
904
}
905

906
// Title overlay at bottom - more eye-catching
907
.title-overlay {
908
  position: absolute;
909
  bottom: 0;
910
  left: 0;
911
  right: 0;
912
  padding: 1rem 1rem 0.75rem;
913
  background: linear-gradient(
914
    to top,
915
    rgba(0, 0, 0, 0.92) 0%,
916
    rgba(0, 0, 0, 0.9) 8%,
917
    rgba(0, 0, 0, 0.86) 16%,
918
    rgba(0, 0, 0, 0.8) 24%,
919
    rgba(0, 0, 0, 0.7) 32%,
920
    rgba(0, 0, 0, 0.58) 42%,
921
    rgba(0, 0, 0, 0.44) 52%,
922
    rgba(0, 0, 0, 0.3) 62%,
923
    rgba(0, 0, 0, 0.18) 72%,
924
    rgba(0, 0, 0, 0.1) 82%,
925
    rgba(0, 0, 0, 0.04) 92%,
926
    rgba(0, 0, 0, 0) 100%
927
  );
928
  color: $color-white;
929
  z-index: 10;
930
  display: flex;
931
  flex-direction: column;
932
  align-items: stretch;
933
}
934

935
.info-row {
936
  display: flex;
937
  justify-content: space-between;
938
  align-items: center;
939
  gap: 0.5rem;
940
  margin-bottom: 0.25rem;
941
}
942

943
.info-icons {
944
  display: flex;
945
  align-items: center;
946
  gap: 0.35rem;
947
  font-size: 0.7rem;
948

949
  @include media-breakpoint-up(md) {
950
    gap: 0.5rem;
951
    font-size: 0.85rem;
952
  }
953
}
954

955
.location,
956
.time,
957
.replies,
958
.delivery,
959
.deadline {
960
  display: flex;
961
  align-items: center;
962
  gap: 0.2rem;
963
  background: $color-black-opacity-50;
964
  padding: 0.15rem 0.4rem;
965
  backdrop-filter: blur(4px);
966
}
967

968
.location {
969
  cursor: pointer;
970
}
971

972
.title-row {
973
  width: 100%;
974
  min-width: 0;
975
}
976

977
.title-tag {
978
  font-size: 0.9rem !important;
979
  white-space: nowrap !important;
980
  flex-shrink: 0;
981

982
  @include media-breakpoint-up(md) {
983
    font-size: 1rem !important;
984
  }
985
}
986

987
.title-subject {
988
  display: block;
989
  width: 100%;
990
  font-size: clamp(1rem, 4vw, 1.25rem);
991
  font-weight: 700;
992
  line-height: 1.2;
993
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
994

995
  @include media-breakpoint-up(md) {
996
    font-size: clamp(1.25rem, 3.5vw, 1.75rem);
997
  }
998
}
999

1000
.title-location {
1001
  font-size: 0.85rem;
1002
  opacity: 0.85;
1003
  margin-top: 0.15rem;
1004

1005
  @include media-breakpoint-up(md) {
1006
    font-size: 1rem;
1007
  }
1008
}
1009

1010
.photo-counter {
1011
  position: absolute;
1012
  top: 1rem;
1013
  right: 1rem;
1014
  background: $color-black-opacity-60;
1015
  color: $color-white;
1016
  padding: 0.25rem 0.6rem;
1017
  border-radius: 1rem;
1018
  font-size: 0.8rem;
1019
  z-index: 11;
1020
}
1021

1022
// Info Section - natural height, scrolls with main container
1023
.info-section {
1024
  flex: 0 0 auto;
1025
  padding: 1rem;
1026
}
1027

1028
// Poster overlay on photo (shown on shorter screens)
1029
.poster-overlay {
1030
  display: none;
1031
  position: absolute;
1032
  top: 1rem;
1033
  right: 1rem;
1034
  background: $color-white-opacity-95;
1035
  backdrop-filter: blur(8px);
1036
  color: $color-gray--darker;
1037
  padding: 0.4rem 0.6rem;
1038
  z-index: 11;
1039
  text-decoration: none;
1040
  max-width: 60%;
1041
  align-items: center;
1042
  gap: 0.5rem;
1043
  box-shadow: 0 2px 8px $color-black-opacity-15;
1044
  border: 1px solid $color-gray-3;
1045

1046
  &:hover {
1047
    background: $color-white;
1048
    color: $color-gray--darker;
1049
    text-decoration: none;
1050
  }
1051

1052
  &--below-carousel {
1053
    top: 75px;
1054
  }
1055

1056
  @media (max-height: 700px) {
1057
    display: flex;
1058
  }
1059
}
1060

1061
.poster-overlay-avatar-wrapper {
1062
  position: relative;
1063
  flex-shrink: 0;
1064
}
1065

1066
.poster-overlay-avatar {
1067
  :deep(.ProfileImage__container) {
1068
    width: 28px !important;
1069
    height: 28px !important;
1070
  }
1071
}
1072

1073
.supporter-badge-small {
1074
  position: absolute;
1075
  bottom: -2px;
1076
  right: -2px;
1077
  background: gold;
1078
  color: $color-white;
1079
  width: 14px;
1080
  height: 14px;
1081
  border-radius: 50%;
1082
  display: flex;
1083
  align-items: center;
1084
  justify-content: center;
1085
  border: 1px solid $color-white;
1086
  font-size: 0.45rem;
1087
}
1088

1089
.poster-overlay-info {
1090
  display: flex;
1091
  flex-direction: column;
1092
  min-width: 0;
1093
}
1094

1095
.poster-overlay-name {
1096
  font-size: 0.75rem;
1097
  font-weight: 600;
1098
  white-space: nowrap;
1099
  overflow: hidden;
1100
  text-overflow: ellipsis;
1101
}
1102

1103
.poster-overlay-stats {
1104
  display: flex;
1105
  gap: 0.5rem;
1106
  font-size: 0.65rem;
1107
  color: $color-gray--dark;
1108
}
1109

1110
.poster-overlay-stat {
1111
  display: flex;
1112
  align-items: center;
1113
  gap: 0.15rem;
1114
}
1115

1116
.poster-overlay-chevron {
1117
  flex-shrink: 0;
1118
  color: $color-gray--dark;
1119
  font-size: 0.9rem;
1120
  margin-left: auto;
1121
}
1122

1123
/* Section header with label on left, ID link on right */
1124
.section-header {
1125
  display: flex;
1126
  align-items: center;
1127
  justify-content: space-between;
1128
  margin-top: 1rem;
1129
  margin-bottom: 0.5rem;
1130
  border-bottom: 1px solid $color-gray-3;
1131
  padding-bottom: 0.25rem;
1132

1133
  /* POSTED BY header hides on short screens where overlay is shown */
1134
  &--poster {
1135
    @media (max-height: 700px) {
1136
      display: none;
1137
    }
1138
  }
1139
}
1140

1141
.section-header-text {
1142
  font-size: 0.7rem;
1143
  font-weight: 600;
1144
  color: $color-gray--base;
1145
  letter-spacing: 0.1em;
1146
}
1147

1148
.section-id-link {
1149
  font-size: 0.7rem;
1150
  font-weight: 500;
1151
  color: $color-gray--base;
1152
  text-decoration: none;
1153

1154
  &:hover {
1155
    color: $color-gray--dark;
1156
    text-decoration: underline;
1157
  }
1158
}
1159

1160
/* Poster section wrapper - now a link for tablet layout with ratings */
1161
.poster-section-wrapper {
1162
  display: flex;
1163
  align-items: flex-start;
1164
  flex-wrap: wrap;
1165
  gap: 0.5rem;
1166
  padding: 0.75rem 1rem;
1167
  margin-top: 0.5rem;
1168
  text-decoration: none;
1169
  color: inherit;
1170
  background: $colour-info-bg;
1171
  border-left: 3px solid $colour-info-fg;
1172

1173
  &:hover {
1174
    text-decoration: none;
1175
    color: inherit;
1176
    background: darken($colour-info-bg, 3%);
1177
  }
1178

1179
  /* Hide on short screens where overlay is shown */
1180
  @media (max-height: 700px) {
1181
    display: none;
1182
  }
1183

1184
  /* Very narrow screens: stack vertically */
1185
  @media (max-width: 320px) {
1186
    flex-direction: column;
1187
    align-items: stretch;
1188
  }
1189
}
1190

1191
/* Poster aboutme - hidden on mobile, shown on tablet */
1192
.poster-aboutme {
1193
  display: none;
1194
  font-size: 0.85rem;
1195
  line-height: 1.5;
1196
  color: $color-gray--darker;
1197
  margin-top: 0.5rem;
1198
  font-style: italic;
1199
  -webkit-line-clamp: 6;
1200
  -webkit-box-orient: vertical;
1201
  overflow: hidden;
1202

1203
  &::before {
1204
    content: '"';
1205
  }
1206

1207
  &::after {
1208
    content: '"';
1209
  }
1210

1211
  @include media-breakpoint-up(md) {
1212
    display: -webkit-box;
1213
  }
1214
}
1215

1216
/* Poster ratings - hidden on mobile, shown on tablet */
1217
.poster-ratings {
1218
  display: none !important;
1219
  flex-shrink: 0;
1220

1221
  @include media-breakpoint-up(md) {
1222
    display: flex !important;
1223
  }
1224
}
1225

1226
.poster-avatar-wrapper {
1227
  position: relative;
1228
  flex-shrink: 0;
1229
}
1230

1231
.poster-avatar {
1232
  :deep(.ProfileImage__container) {
1233
    width: 48px !important;
1234
    height: 48px !important;
1235
  }
1236
}
1237

1238
.supporter-badge {
1239
  position: absolute;
1240
  bottom: 0;
1241
  right: 0;
1242
  background: gold;
1243
  color: $color-white;
1244
  width: 20px;
1245
  height: 20px;
1246
  border-radius: 50%;
1247
  display: flex;
1248
  align-items: center;
1249
  justify-content: center;
1250
  border: 2px solid $color-white;
1251
  font-size: 0.6rem;
1252
}
1253

1254
.poster-details {
1255
  flex: 1;
1256
  min-width: 0;
1257
  display: flex;
1258
  flex-direction: column;
1259
  gap: 0.15rem;
1260
  overflow: hidden;
1261
}
1262

1263
.poster-name {
1264
  font-size: 1rem;
1265
  font-weight: 600;
1266
  color: $color-gray--darker;
1267
  white-space: nowrap;
1268
  overflow: hidden;
1269
  text-overflow: ellipsis;
1270
}
1271

1272
.poster-stats {
1273
  display: flex;
1274
  align-items: center;
1275
  flex-wrap: wrap;
1276
  gap: 0.5rem;
1277
  font-size: 0.8rem;
1278
  color: $color-gray--dark;
1279
}
1280

1281
.poster-distance,
1282
.poster-stat {
1283
  display: flex;
1284
  align-items: center;
1285
  gap: 0.2rem;
1286
}
1287

1288
.poster-stat-label {
1289
  display: none;
1290
  margin-left: 0.15rem;
1291

1292
  @include media-breakpoint-up(md) {
1293
    display: inline;
1294
  }
1295
}
1296

1297
.poster-chevron {
1298
  flex-shrink: 0;
1299
  align-self: center;
1300
  color: $color-gray--dark;
1301
  font-size: 1.25rem;
1302
  padding: 0.5rem;
1303
  margin-right: -0.5rem;
1304
}
1305

1306
// Description
1307
.description-section {
1308
  margin-bottom: 1rem;
1309
}
1310

1311
.description-content {
1312
  background: $color-gray-3;
1313
  border-left: 3px solid $color-green--darker;
1314
  padding: 1rem;
1315
  border-radius: 0 8px 8px 0;
1316
  font-size: 1rem;
1317
  line-height: 1.7;
1318
  color: $color-gray--darker;
1319
}
1320

1321
.app-footer {
1322
  position: fixed;
1323
  bottom: 0;
1324
  left: 0;
1325
  right: 0;
1326
  padding: 1rem;
1327
  border-top: 1px solid $color-gray-3;
1328
  background: $color-white;
1329
  z-index: 1100;
1330

1331
  &.stickyAdRendered {
1332
    bottom: $sticky-banner-height-mobile;
1333

1334
    @media (min-height: $mobile-tall) {
1335
      bottom: $sticky-banner-height-mobile-tall;
1336
    }
1337
  }
1338

1339
  /* When inside a b-modal, disable fixed positioning */
1340
  .in-modal & {
1341
    position: relative;
1342
    bottom: auto;
1343
    left: auto;
1344
    right: auto;
1345
    z-index: auto;
1346

1347
    &.stickyAdRendered {
1348
      bottom: auto;
1349
    }
1350
  }
1351
}
1352

1353
.footer-buttons {
1354
  display: flex;
1355
  gap: 0.75rem;
1356
  width: 100%;
1357

1358
  .cancel-button,
1359
  .reply-button {
1360
    flex: 1;
1361
    width: auto !important;
1362
    display: flex !important;
1363
    justify-content: center;
1364
  }
1365

1366
  /* Mobile: only Reply button visible, full width */
1367
  @media (max-width: 767.98px) {
1368
    .cancel-button {
1369
      display: none !important;
1370
    }
1371

1372
    .reply-button {
1373
      width: 100% !important;
1374
    }
1375
  }
1376
}
1377

1378
/* When only Cancel button is shown (own posts), full width */
1379
.footer-buttons:has(.cancel-button:only-child) .cancel-button {
1380
  flex: 1;
1381
  width: 100% !important;
1382
}
1383

1384
.reply-expanded-section {
1385
  max-height: 70vh;
1386
  overflow-y: auto;
1387
}
1388

1389
.promised-notice {
1390
  text-align: center;
1391
  color: $color-orange--dark;
1392
  font-size: 0.85rem;
1393
  font-weight: 500;
1394
}
1395

1396
// Fullscreen map viewer
1397
.fullscreen-map-viewer {
1398
  position: fixed;
1399
  top: 0;
1400
  left: 0;
1401
  right: 0;
1402
  bottom: 0;
1403
  background: $color-gray--lighter;
1404
  z-index: 10000;
1405
  display: flex;
1406
  flex-direction: column;
1407
}
1408

1409
.map-back-button {
1410
  position: absolute;
1411
  top: env(safe-area-inset-top, 0);
1412
  left: 0;
1413
  margin: 1rem;
1414
  width: 44px;
1415
  height: 44px;
1416
  border-radius: 50%;
1417
  background: $color-white-opacity-95;
1418
  border: none;
1419
  color: $color-gray--darker;
1420
  display: flex;
1421
  align-items: center;
1422
  justify-content: center;
1423
  cursor: pointer;
1424
  z-index: 10001;
1425
  font-size: 1.25rem;
1426
  box-shadow: 0 2px 8px $color-black-opacity-20;
1427

1428
  &:active {
1429
    background: $color-white;
1430
  }
1431
}
1432

1433
.fullscreen-map {
1434
  flex: 1;
1435
  width: 100%;
1436
  height: 100% !important;
1437

1438
  :deep(.leaflet-container) {
1439
    height: 100% !important;
1440
  }
1441
}
1442

1443
.map-hint {
1444
  position: absolute;
1445
  bottom: env(safe-area-inset-bottom, 0);
1446
  left: 0;
1447
  right: 0;
1448
  margin-bottom: 1rem;
1449
  padding: 0.5rem 1rem;
1450
  background: $color-white-opacity-90;
1451
  color: $color-gray--dark;
1452
  font-size: 0.85rem;
1453
  text-align: center;
1454
  margin-left: 1rem;
1455
  margin-right: 1rem;
1456
  border-radius: 8px;
1457
  box-shadow: 0 2px 8px $color-black-opacity-10;
1458
}
1459
</style>
1460

1461
<!-- Unscoped styles for Ken Burns - scoped :deep() doesn't penetrate NuxtPicture -->
1462
<style lang="scss">
1463
// Ken Burns effect - slow pan and zoom
1464
@keyframes kenburns {
1465
  0% {
1466
    transform: scale(1.15) translate(0%, 3%);
1467
  }
1468
  25% {
1469
    transform: scale(1.15) translate(-3%, 0%);
1470
  }
1471
  50% {
1472
    transform: scale(1.15) translate(0%, -3%);
1473
  }
1474
  75% {
1475
    transform: scale(1.15) translate(3%, 0%);
1476
  }
1477
  100% {
1478
    transform: scale(1.15) translate(0%, 3%);
1479
  }
1480
}
1481

1482
.photo-container.ken-burns img {
1483
  animation: kenburns 20s ease-in-out infinite;
1484
  will-change: transform;
1485
  transform-origin: center center;
1486
}
1487

1488
@media (prefers-reduced-motion: reduce) {
1489
  .photo-container.ken-burns img {
1490
    animation: none !important;
1491
  }
1492
}
1493
</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