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

Freegle / iznik-nuxt3 / 2ff86d7a-cd6f-4534-99a0-d87b091004db

03 Jan 2026 11:02PM UTC coverage: 42.782%. Remained the same
2ff86d7a-cd6f-4534-99a0-d87b091004db

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

3474 of 8207 branches covered (42.33%)

Branch coverage included in aggregate %.

3 of 10 new or added lines in 3 files covered. (30.0%)

40 existing lines in 6 files now uncovered.

1825 of 4179 relevant lines covered (43.67%)

77.74 hits per line

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

46.4
/components/MessageExpanded.vue
1
<template>
2
  <div
3
    v-if="message"
107!
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"
294!
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"
107!
37
            lazy
38
            src="/freegled.jpg"
39
            class="status-overlay-image"
40
            :alt="successfulText"
41
          />
42
          <b-img
43
            v-else-if="message.promised"
107!
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"
107!
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"
107✔
100
            class="photo-container"
101
            :class="{ 'ken-burns': showKenBurns }"
57!
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
49✔
145
              v-if="poster"
49✔
146
              class="poster-overlay"
147
              :class="{ 'poster-overlay--below-carousel': attachmentCount > 1 }"
148
              @click.stop="showProfileModal = true"
54✔
149
            >
150
              <div class="poster-overlay-avatar-wrapper">
151
                <ProfileImage
152
                  :image="poster.profile?.paththumb"
27!
153
                  :externaluid="poster.profile?.externaluid"
27!
154
                  :ouruid="poster.profile?.ouruid"
27!
155
                  :externalmods="poster.profile?.externalmods"
27!
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">
27!
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 }}
81✔
172
                  </span>
173
                  <span v-if="poster.info?.wanteds" class="poster-overlay-stat">
174
                    <v-icon icon="search" />{{ poster.info.wanteds }}
175
                  </span>
176
                </div>
177
              </div>
178
              <v-icon icon="chevron-right" class="poster-overlay-chevron" />
179
            </div>
180
          </client-only>
181

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

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

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

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

421
            <!-- Expanded reply section -->
422
            <div v-else class="reply-expanded-section">
423
              <NoticeMessage
424
                v-if="message.promised && !message.promisedtome"
107!
425
                variant="warning"
426
                class="mb-2"
427
              >
428
                Already promised - you might not get it.
107✔
429
              </NoticeMessage>
430
              <client-only>
431
                <MessageReplySection
196!
432
                  :id="id"
433
                  @close="replyExpanded = false"
434
                  @sent="sent"
435
                />
×
436
              </client-only>
437
            </div>
438
          </div>
490✔
439
        </div>
440
      </div>
441
    </div>
442

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

107!
491
      <!-- Expanded reply section -->
492
      <div v-else class="reply-expanded-section">
×
493
        <NoticeMessage
494
          v-if="message.promised && !message.promisedtome"
495
          variant="warning"
496
          class="mb-2"
497
        >
×
498
          Already promised - you might not get it.
499
        </NoticeMessage>
500
        <client-only>
501
          <MessageReplySection
502
            :id="id"
503
            @close="replyExpanded = false"
504
            @sent="sent"
×
505
          />
506
        </client-only>
507
      </div>
508
    </div>
509

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

530
    <!-- Photos Modal -->
531
    <MessagePhotosModal
532
      v-if="showMessagePhotosModal && attachmentCount"
533
      :id="message.id"
534
      :initial-index="currentPhotoIndex"
535
      @hidden="showMessagePhotosModal = false"
536
    />
537

538
    <!-- Share Modal -->
539
    <MessageShareModal
540
      v-if="showShareModal && message.url"
541
      :id="message.id"
542
      @hidden="showShareModal = false"
543
    />
544

545
    <!-- Profile Modal -->
546
    <ProfileModal
547
      v-if="showProfileModal && poster?.id"
548
      :id="poster.id"
549
      @hidden="showProfileModal = false"
550
    />
551

552
    <!-- Report Modal -->
27✔
553
    <MessageReportModal
554
      v-if="showReportModal"
555
      :id="id"
556
      @hidden="showReportModal = false"
557
    />
558
  </div>
559
</template>
560

561
<script setup>
562
import {
563
  ref,
564
  computed,
565
  defineAsyncComponent,
566
  onMounted,
567
  onUnmounted,
568
} from 'vue'
569
import { useMiscStore } from '~/stores/misc'
570
import { useMe } from '~/composables/useMe'
571
import { useMessageDisplay } from '~/composables/useMessageDisplay'
572
import MessageTextBody from '~/components/MessageTextBody'
573
import MessageTag from '~/components/MessageTag'
574
import NoticeMessage from '~/components/NoticeMessage'
575
import MessageReplySection from '~/components/MessageReplySection'
576
import ProfileImage from '~/components/ProfileImage'
577
import UserRatings from '~/components/UserRatings'
578
import { useModalHistory } from '~/composables/useModalHistory'
579

580
const MessageMap = defineAsyncComponent(() => import('~/components/MessageMap'))
581
const MessagePhotosModal = defineAsyncComponent(() =>
582
  import('~/components/MessagePhotosModal')
583
)
584
const MessageShareModal = defineAsyncComponent(() =>
585
  import('~/components/MessageShareModal')
586
)
587
const ProfileModal = defineAsyncComponent(() =>
588
  import('~/components/ProfileModal')
589
)
590
const MessageReportModal = defineAsyncComponent(() =>
591
  import('~/components/MessageReportModal')
592
)
593

594
const props = defineProps({
595
  id: {
596
    type: Number,
597
    required: true,
598
  },
599
  replyable: {
600
    type: Boolean,
601
    default: true,
602
  },
603
  hideClose: {
604
    type: Boolean,
605
    default: false,
606
  },
607
  actions: {
608
    type: Boolean,
609
    default: true,
610
  },
611
  inModal: {
612
    type: Boolean,
613
    default: false,
614
  },
615
  fullscreenOverlay: {
616
    type: Boolean,
617
    default: false,
618
  },
619
})
620

621
const emit = defineEmits(['zoom', 'close'])
622

623
const miscStore = useMiscStore()
624
const { me, loggedIn } = useMe()
625

626
// Use shared composable for common message display logic
627
const {
628
  message,
629
  subjectItemName,
630
  subjectLocation,
631
  fromme,
632
  gotAttachments,
633
  attachmentCount,
634
  timeAgo,
635
  fullTimeAgo,
9!
636
  distanceText,
637
  replyCount,
638
  replyTooltip,
UNCOV
639
  isOffer,
×
640
  formattedDeadline,
641
  deadlineTooltip,
642
  successfulText,
UNCOV
643
  placeholderClass,
×
644
  categoryIcon,
645
  poster,
646
} = useMessageDisplay(props.id)
647

648
const stickyAdRendered = computed(() => miscStore.stickyAdRendered)
649

650
// State
5!
651
const replied = ref(false)
652
const replyExpanded = ref(false)
653
const showMapModal = ref(false)
654
const showShareModal = ref(false)
655
const showProfileModal = ref(false)
656
const showMessagePhotosModal = ref(false)
657
const showReportModal = ref(false)
15✔
658
const currentPhotoIndex = ref(0)
659
const containerRef = ref(null)
660
const thumbnailsRef = ref(null)
661
const thumbnailTouchStartX = ref(0)
662
const thumbnailScrollStart = ref(0)
54✔
663
let thumbnailScrollInterval = null
664

665
// Computed (additional to composable)
666
const currentAttachment = computed(() => {
667
  return message.value?.attachments?.[currentPhotoIndex.value]
668
})
669

670
const validPosition = computed(() => {
103✔
671
  return message.value?.lat || message.value?.lng
672
})
54✔
673

674
const home = computed(() => {
5✔
675
  if (me.value?.lat || me.value?.lng) {
676
    return { lat: me.value.lat, lng: me.value.lng }
5✔
677
  }
678
  return null
679
})
680

49!
681
const prefersReducedMotion = computed(() => {
49✔
682
  if (typeof window === 'undefined') return false
683
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
684
})
685

UNCOV
686
// Defer ken-burns animation to after mount to avoid SSR hydration mismatch
×
UNCOV
687
const isMounted = ref(false)
×
688
const showKenBurns = computed(() => {
689
  return isMounted.value && !prefersReducedMotion.value
UNCOV
690
})
×
UNCOV
691

×
692
// Check if navbar-mobile teleport target exists (only after mount to avoid hydration mismatch)
693
const navbarMobileExists = computed(() => {
694
  if (!isMounted.value) return false
695
  return !!document.getElementById('navbar-mobile')
696
})
UNCOV
697

×
UNCOV
698
// Detect two-column layout (width >= xl breakpoint AND height <= 700px)
×
699
// Only evaluate after mount to avoid SSR hydration mismatch
700
const windowHeight = ref(0)
UNCOV
701
const isTwoColumnLayout = computed(() => {
×
UNCOV
702
  if (!isMounted.value) return false
×
703
  // Only use 2-column layout in modal or overlay - standalone pages use single column
704
  if (!props.inModal && !props.fullscreenOverlay) return false
705
  // Use miscStore breakpoint for width (xl = 1200px+)
706
  const isWideEnough = ['xl', 'xxl'].includes(miscStore.breakpoint)
707
  const isShortEnough = windowHeight.value <= 700
708
  return isWideEnough && isShortEnough
709
})
UNCOV
710

×
UNCOV
711
const posterAboutMe = computed(() => {
×
712
  const text = poster.value?.aboutme?.text
×
713
  if (!text) return null
714
  return text
UNCOV
715
})
×
UNCOV
716

×
UNCOV
717
// Methods
×
718
function goBack() {
×
719
  emit('close')
720
}
UNCOV
721

×
722
function showPhotosModal() {
723
  if (gotAttachments.value) {
724
    showMessagePhotosModal.value = true
725
    emit('zoom')
27✔
726
  }
27!
727
}
728

729
function showShare() {
NEW
730
  showShareModal.value = true
×
NEW
731
}
×
732

NEW
733
function showReport() {
×
734
  showReportModal.value = true
735
}
NEW
736

×
737
function selectPhoto(index) {
738
  currentPhotoIndex.value = index
739
}
UNCOV
740

×
741
function handleThumbnailClick(index) {
×
742
  if (index === currentPhotoIndex.value) {
×
743
    // Clicking the already selected thumbnail opens the photo viewer
744
    showPhotosModal()
745
  } else {
746
    selectPhoto(index)
×
747
  }
748
}
UNCOV
749

×
750
function onThumbnailTouchStart(e) {
751
  thumbnailTouchStartX.value = e.touches[0].clientX
×
752
  thumbnailScrollStart.value = thumbnailsRef.value?.scrollLeft || 0
753
}
754

755
function onThumbnailTouchMove(e) {
756
  if (!thumbnailsRef.value) return
×
757
  const deltaX = thumbnailTouchStartX.value - e.touches[0].clientX
×
758
  thumbnailsRef.value.scrollLeft = thumbnailScrollStart.value + deltaX
×
UNCOV
759
}
×
UNCOV
760

×
761
function onThumbnailTouchEnd() {
762
  // Swipe complete, scroll position is already set
763
}
×
764

765
function startThumbnailAutoScroll() {
UNCOV
766
  if (!thumbnailsRef.value || attachmentCount.value <= 1) return
×
767

×
768
  // Wait a moment then scroll right slowly, then back
769
  setTimeout(() => {
770
    if (!thumbnailsRef.value) return
771
    const maxScroll =
×
772
      thumbnailsRef.value.scrollWidth - thumbnailsRef.value.clientWidth
773
    if (maxScroll <= 0) return
774

UNCOV
775
    // Animate scroll manually for smoother control
×
776
    const duration = 2000 // 2 seconds to scroll
777
    const startTime = performance.now()
778
    const startPos = 0
779

780
    function animateScroll(currentTime) {
781
      if (!thumbnailsRef.value) return
782
      const elapsed = currentTime - startTime
783
      const progress = Math.min(elapsed / duration, 1)
784
      // Ease in-out
785
      const eased =
786
        progress < 0.5
22✔
787
          ? 2 * progress * progress
22✔
788
          : 1 - Math.pow(-2 * progress + 2, 2) / 2
789
      thumbnailsRef.value.scrollLeft = startPos + maxScroll * eased
790

791
      if (progress < 1) {
792
        requestAnimationFrame(animateScroll)
793
      } else {
794
        // Pause then scroll back
795
        setTimeout(() => {
796
          if (!thumbnailsRef.value) return
797
          const backStartTime = performance.now()
798
          function animateBack(currentTime) {
7✔
799
            if (!thumbnailsRef.value) return
7✔
800
            const elapsed = currentTime - backStartTime
801
            const progress = Math.min(elapsed / duration, 1)
802
            const eased =
803
              progress < 0.5
2✔
804
                ? 2 * progress * progress
805
                : 1 - Math.pow(-2 * progress + 2, 2) / 2
806
            thumbnailsRef.value.scrollLeft = maxScroll - maxScroll * eased
807
            if (progress < 1) {
808
              requestAnimationFrame(animateBack)
27✔
809
            }
810
          }
27✔
811
          requestAnimationFrame(animateBack)
27✔
812
        }, 1000)
813
      }
814
    }
815
    requestAnimationFrame(animateScroll)
816
  }, 1000)
27✔
817
}
818

819
function stopThumbnailAutoScroll() {
820
  if (thumbnailScrollInterval) {
821
    clearInterval(thumbnailScrollInterval)
822
    thumbnailScrollInterval = null
823
  }
824
}
825

826
function expandReply() {
827
  console.log(
828
    'DEBUG expandReply called, replyable:',
15✔
829
    props.replyable,
830
    'replied:',
831
    replied.value,
832
    'fromme:',
833
    fromme.value
834
  )
835
  replyExpanded.value = true
836
}
837

838
function sent() {
839
  replyExpanded.value = false
840
  replied.value = true
841
  // Close after a brief delay so user sees confirmation
842
  setTimeout(() => {
843
    emit('close')
844
  }, 1500)
845
}
846

847
// Handle browser back button/swipe
848
useModalHistory(`message-${props.id}`, () => emit('close'), true)
849

850
function updateWindowHeight() {
851
  windowHeight.value = window.innerHeight
852
}
853

854
onMounted(() => {
855
  // Enable ken-burns animation now that hydration is complete
856
  isMounted.value = true
857

858
  // Track window height for two-column layout detection (width via miscStore.breakpoint)
859
  updateWindowHeight()
860
  window.addEventListener('resize', updateWindowHeight)
861

862
  // Start auto-scroll hint for thumbnail carousel
863
  startThumbnailAutoScroll()
864
})
865

866
onUnmounted(() => {
867
  stopThumbnailAutoScroll()
868
  window.removeEventListener('resize', updateWindowHeight)
869
})
870
</script>
871

872
<style scoped lang="scss">
873
@import 'bootstrap/scss/functions';
874
@import 'bootstrap/scss/variables';
875
@import 'bootstrap/scss/mixins/_breakpoints';
876
@import 'assets/css/sticky-banner.scss';
877
@import 'assets/css/_color-vars.scss';
878
@import 'assets/css/navbar.scss';
879

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

1073
/* Main wrapper - handles both modal and page contexts */
1074
.message-expanded-wrapper {
1075
  display: grid;
1076
  grid-template-rows: 1fr auto;
1077
  background: $color-white;
1078
  position: relative;
1079

1080
  /* When used inside a b-modal - let modal handle positioning */
1081
  &.in-modal {
1082
    position: relative;
1083
    width: 100%;
1084
    height: 100%;
1085
    flex: 1; /* Fill the flex container (modal-body uses display: flex) */
1086
    min-height: 0; /* Allow flex shrinking */
1087
  }
1088

1089
  /* When used as fullscreen overlay (mobile expand) */
1090
  &.fullscreen-overlay {
1091
    position: fixed;
1092
    top: 0;
1093
    left: 0;
1094
    width: 100%;
1095
    height: 100%;
1096
    z-index: 1050;
1097
    overflow: hidden;
1098
    overflow-x: hidden;
1099
  }
1100
}
1101

1102
/* Main content area - single column by default */
1103
.message-expanded-mobile {
1104
  display: contents;
1105
}
1106

1107
/* Two-column layout wrapper */
1108
.two-column-wrapper {
1109
  display: grid;
1110
  grid-template-columns: 1fr;
1111
  grid-template-rows: auto 1fr;
1112
  min-height: 0;
1113

1114
  /* Two-column layout only in modal/overlay contexts */
1115
  .in-modal &,
1116
  .fullscreen-overlay & {
1117
    /* Use flexbox for priority-based sizing: photo grows, info sizes to content */
1118
    display: flex;
1119
    flex-direction: column;
1120
    overflow: hidden;
1121

1122
    /* Two-column on xl+ screens with short height (≤700px) */
1123
    @media (min-width: 1200px) and (max-height: 700px) {
1124
      display: grid;
1125
      grid-template-columns: 1fr 1fr;
1126
      grid-template-rows: 1fr;
1127
      align-items: stretch;
1128
      height: 100%;
1129
    }
1130
  }
1131
}
1132

1133
/* Right column for two-column layout - only active in modal/overlay */
1134
.right-column {
1135
  display: contents;
1136

1137
  /* Two-column mode: use grid for precise control - only in modal/overlay */
1138
  .in-modal &,
1139
  .fullscreen-overlay & {
1140
    @media (min-width: 1200px) and (max-height: 700px) {
1141
      display: grid;
1142
      grid-template-rows: minmax(0, 1fr) auto;
1143
      min-width: 0;
1144
      height: 100%;
1145
      overflow-y: auto;
1146
      overscroll-behavior: contain;
1147
    }
1148
  }
1149
}
1150

1151
/* Inline reply section - hidden by default, shown in two-column layout (modal/overlay only) */
1152
.inline-reply-section {
1153
  display: none;
1154

1155
  .in-modal &,
1156
  .fullscreen-overlay & {
1157
    @media (min-width: 1200px) and (max-height: 700px) {
1158
      display: block;
1159
      flex-shrink: 0;
1160
      padding: 1rem;
1161
      border-top: 1px solid $color-gray-3;
1162
      background: $color-white;
1163
    }
1164
  }
1165
}
1166

1167
/* Photo Area - flexible height, fills available space */
1168
.photo-area {
1169
  position: relative;
1170
  width: 100%;
1171
  min-height: 150px;
1172
  max-height: 50vh;
1173
  overflow: hidden;
1174
  background: $color-gray--lighter;
1175
  cursor: pointer;
1176

1177
  /* In modal/fullscreen: grow to fill available space, shrink if needed */
1178
  .in-modal &,
1179
  .fullscreen-overlay & {
1180
    flex: 1 1 0;
1181
    max-height: none;
1182
    min-height: 100px;
1183
  }
1184

1185
  /* Two-column layout: photo fills full height of left column */
1186
  @media (min-width: 1200px) and (max-height: 700px) {
1187
    max-height: none;
1188
    height: 100%;
1189
  }
1190
}
1191

1192
// Photo container - positioned to fill photo-area
1193
.photo-container {
1194
  width: 100%;
1195
  height: 100%;
1196
  position: absolute;
1197
  top: 0;
1198
  left: 0;
1199
  overflow: hidden;
1200
}
1201

1202
// All image elements fill container
1203
.photo-container :deep(picture),
1204
.photo-container :deep(img) {
1205
  width: 100%;
1206
  height: 100%;
1207
  object-fit: cover;
1208
  display: block;
1209
}
1210

1211
// Ken Burns animation is in unscoped style block at end of file
1212

1213
// Stats pills inside title overlay
1214
.stats-pills {
1215
  display: flex;
1216
  justify-content: space-between;
1217
  align-items: center;
1218
  width: 100%;
1219
  margin-top: 0.5rem;
1220
}
1221

1222
.pills-left {
1223
  display: flex;
1224
  flex-wrap: wrap;
1225
  gap: 0.25rem;
1226
}
1227

1228
.pills-right {
1229
  display: flex;
1230
  flex-wrap: wrap;
1231
  gap: 0.25rem;
1232
}
1233

1234
.stat-pill {
1235
  display: inline-flex;
1236
  align-items: center;
1237
  gap: 0.15rem;
1238
  background: $color-white-opacity-25;
1239
  color: $color-white;
1240
  padding: 0.15rem 0.4rem;
1241
  border-radius: 1rem;
1242
  font-size: 0.7rem;
1243

1244
  &.clickable {
1245
    cursor: pointer;
1246
    background: $color-blue--bright;
1247
  }
1248
}
1249

1250
.delivery-maybe {
1251
  font-weight: bold;
1252
  font-size: 0.8rem;
1253
  margin-left: -0.1rem;
1254
}
1255

1256
// Status overlay image (promised/freegled)
1257
.status-overlay-image {
1258
  position: absolute;
1259
  z-index: 10;
1260
  transform: rotate(15deg);
1261
  top: 50%;
1262
  left: 50%;
1263
  width: 50%;
1264
  max-width: 200px;
1265
  margin-left: -25%;
1266
  margin-top: -15%;
1267
  pointer-events: none;
1268
}
1269

1270
// Thumbnail carousel at top of photo area
1271
.thumbnail-carousel {
1272
  position: absolute;
1273
  top: 1rem;
1274
  left: 50%;
1275
  transform: translateX(-50%);
1276
  z-index: 11;
1277
  display: flex;
1278
  justify-content: center;
1279
  gap: 8px;
1280
  overflow-x: auto;
1281
  scrollbar-width: none;
1282
  -ms-overflow-style: none;
1283
  padding: 4px;
1284
  max-width: calc(100% - 120px);
1285

1286
  &::-webkit-scrollbar {
1287
    display: none;
1288
  }
1289
}
1290

1291
.thumbnail-item {
1292
  flex-shrink: 0;
1293
  width: 50px;
1294
  height: 50px;
1295
  border-radius: 8px;
1296
  overflow: hidden;
1297
  border: 2px solid $color-white-opacity-50;
1298
  cursor: pointer;
1299
  transition: border-color 0.2s, transform 0.2s;
1300

1301
  &.active {
1302
    border-color: $color-white;
1303
    transform: scale(1.1);
1304
  }
1305

1306
  &:not(.active):hover {
1307
    border-color: $color-white-opacity-80;
1308
  }
1309
}
1310

1311
.thumbnail-image {
1312
  width: 100%;
1313
  height: 100%;
1314
  object-fit: cover;
1315
}
1316

1317
// Back button on photo - only shown in fullscreen mode (not in modal)
1318
.back-button {
1319
  position: absolute;
1320
  top: 1rem;
1321
  left: 1rem;
1322
  width: 40px;
1323
  height: 40px;
1324
  border-radius: 50%;
1325
  background: $color-black-opacity-50;
1326
  border: none;
1327
  color: $color-white;
1328
  display: flex;
1329
  align-items: center;
1330
  justify-content: center;
1331
  cursor: pointer;
1332
  z-index: 12;
1333
  font-size: 1.2rem;
1334

1335
  &:hover {
1336
    background: $color-black-opacity-70;
1337
  }
1338

1339
  // Hide when inside a modal (use X close button instead)
1340
  .in-modal & {
1341
    display: none;
1342
  }
1343
}
1344

1345
// Close button for modal (positioned at modal top-right)
1346
.close-button {
1347
  display: none;
1348
  position: absolute;
1349
  top: 0;
1350
  right: 0;
1351
  width: 40px;
1352
  height: 40px;
1353
  border-radius: 50%;
1354
  background: $color-gray--darker;
1355
  border: 2px solid $color-white;
1356
  color: $color-white;
1357
  align-items: center;
1358
  justify-content: center;
1359
  cursor: pointer;
1360
  z-index: 100;
1361
  font-size: 1.2rem;
1362
  box-shadow: 0 2px 8px $color-black-opacity-30;
1363

1364
  &:hover {
1365
    background: $color-gray--dark;
1366
  }
1367

1368
  // Show when inside a modal
1369
  .in-modal & {
1370
    display: flex;
1371
  }
1372
}
1373

1374
// Title overlay at bottom - more eye-catching
1375
.title-overlay {
1376
  position: absolute;
1377
  bottom: 0;
1378
  left: 0;
1379
  right: 0;
1380
  padding: 1rem 1rem 0.75rem;
1381
  background: linear-gradient(
1382
    to top,
1383
    rgba(0, 0, 0, 0.92) 0%,
1384
    rgba(0, 0, 0, 0.9) 8%,
1385
    rgba(0, 0, 0, 0.86) 16%,
1386
    rgba(0, 0, 0, 0.8) 24%,
1387
    rgba(0, 0, 0, 0.7) 32%,
1388
    rgba(0, 0, 0, 0.58) 42%,
1389
    rgba(0, 0, 0, 0.44) 52%,
1390
    rgba(0, 0, 0, 0.3) 62%,
1391
    rgba(0, 0, 0, 0.18) 72%,
1392
    rgba(0, 0, 0, 0.1) 82%,
1393
    rgba(0, 0, 0, 0.04) 92%,
1394
    rgba(0, 0, 0, 0) 100%
1395
  );
1396
  color: $color-white;
1397
  z-index: 10;
1398
  display: flex;
1399
  flex-direction: column;
1400
  align-items: stretch;
1401
}
1402

1403
.info-row {
1404
  display: flex;
1405
  justify-content: space-between;
1406
  align-items: center;
1407
  gap: 0.5rem;
1408
  margin-bottom: 0.25rem;
1409
}
1410

1411
.info-icons {
1412
  display: flex;
1413
  align-items: center;
1414
  gap: 0.35rem;
1415
  font-size: 0.7rem;
1416

1417
  @include media-breakpoint-up(md) {
1418
    gap: 0.5rem;
1419
    font-size: 0.85rem;
1420
  }
1421
}
1422

1423
.location,
1424
.time,
1425
.replies,
1426
.delivery,
1427
.deadline {
1428
  display: flex;
1429
  align-items: center;
1430
  gap: 0.2rem;
1431
  background: $color-black-opacity-50;
1432
  padding: 0.15rem 0.4rem;
1433
  backdrop-filter: blur(4px);
1434
}
1435

1436
.location {
1437
  cursor: pointer;
1438
}
1439

1440
.title-row {
1441
  width: 100%;
1442
  min-width: 0;
1443
}
1444

1445
.location-row {
1446
  display: flex;
1447
  align-items: center;
1448
  justify-content: space-between;
1449
  gap: 8px;
1450
  width: 100%;
1451
}
1452

1453
.photo-actions {
1454
  display: flex;
1455
  gap: 6px;
1456
  flex-shrink: 0;
1457
  margin-left: 8px;
1458
}
1459

1460
.photo-action-btn {
1461
  width: 28px;
1462
  height: 28px;
1463
  border-radius: 50%;
1464
  border: none;
1465
  background: $color-white-opacity-25;
1466
  color: $color-white;
1467
  display: flex;
1468
  align-items: center;
1469
  justify-content: center;
1470
  cursor: pointer;
1471
  transition: all 0.2s;
1472

1473
  &:hover {
1474
    background: $color-white-opacity-50;
1475
  }
1476

1477
  svg {
1478
    font-size: 0.75rem;
1479
  }
1480
}
1481

1482
.title-tag {
1483
  font-size: 0.9rem !important;
1484
  white-space: nowrap !important;
1485
  flex-shrink: 0;
1486

1487
  @include media-breakpoint-up(md) {
1488
    font-size: 1rem !important;
1489
  }
1490
}
1491

1492
.title-subject {
1493
  display: block;
1494
  width: 100%;
1495
  font-size: clamp(1rem, 4vw, 1.25rem);
1496
  font-weight: 700;
1497
  line-height: 1.2;
1498
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
1499

1500
  @include media-breakpoint-up(md) {
1501
    font-size: clamp(1.25rem, 3.5vw, 1.75rem);
1502
  }
1503
}
1504

1505
.title-location {
1506
  font-size: 0.85rem;
1507
  opacity: 0.85;
1508
  margin-top: 0.15rem;
1509

1510
  @include media-breakpoint-up(md) {
1511
    font-size: 1rem;
1512
  }
1513
}
1514

1515
.photo-counter {
1516
  position: absolute;
1517
  top: 1rem;
1518
  right: 1rem;
1519
  background: $color-black-opacity-60;
1520
  color: $color-white;
1521
  padding: 0.25rem 0.6rem;
1522
  border-radius: 1rem;
1523
  font-size: 0.8rem;
1524
  z-index: 11;
1525
}
1526

1527
/* Info Section - scrollable with visible scrollbar */
1528
.info-section {
1529
  min-height: 0;
1530
  padding: 1rem;
1531

1532
  /* Visible scrollbar styling */
1533
  scrollbar-width: thin;
1534
  scrollbar-color: $color-gray--light $color-gray-3;
1535

1536
  &::-webkit-scrollbar {
1537
    width: 8px;
1538
  }
1539

1540
  &::-webkit-scrollbar-track {
1541
    background: $color-gray-3;
1542
  }
1543

1544
  &::-webkit-scrollbar-thumb {
1545
    background: $color-gray--light;
1546
    border-radius: 4px;
1547

1548
    &:hover {
1549
      background: $color-gray--base;
1550
    }
1551
  }
1552

1553
  /* In modal/fullscreen: constrain height and scroll if needed */
1554
  .in-modal &,
1555
  .fullscreen-overlay & {
1556
    flex: 0 0 auto;
1557
    max-height: 50%;
1558
    min-height: 0;
1559
    overflow-y: auto;
1560

1561
    /* Two-column layout: fill available space, scroll if needed - needs same specificity */
1562
    @media (min-width: 1200px) and (max-height: 700px) {
1563
      min-height: 0;
1564
      max-height: 100%;
1565
      overflow-y: auto;
1566
      overscroll-behavior: contain;
1567
    }
1568
  }
1569

1570
  /* Two-column layout on standalone pages: scroll if needed */
1571
  @media (min-width: 1200px) and (max-height: 700px) {
1572
    min-height: 0;
1573
    overflow-y: auto;
1574
  }
1575

1576
  /* In modal: make room for close button */
1577
  .in-modal & {
1578
    padding-top: 2.5rem;
1579
  }
1580
}
1581

1582
// Poster overlay on photo (shown on shorter screens, but NOT in 2-column mode)
1583
// Positioned above the title overlay (badges row)
1584
.poster-overlay {
1585
  display: none;
1586
  position: absolute;
1587
  bottom: 7rem; /* Above title-overlay which has ~6rem height */
1588
  right: 1rem;
1589
  background: $color-white-opacity-95;
1590
  backdrop-filter: blur(8px);
1591
  color: $color-gray--darker;
1592
  padding: 0.4rem 0.6rem;
1593
  z-index: 11;
1594
  text-decoration: none;
1595
  max-width: 60%;
1596
  align-items: center;
1597
  gap: 0.5rem;
1598
  box-shadow: 0 2px 8px $color-black-opacity-15;
1599
  border: 1px solid $color-gray-3;
1600
  cursor: pointer;
1601

1602
  &:hover {
1603
    background: $color-white;
1604
    color: $color-gray--darker;
1605
    text-decoration: none;
1606
  }
1607

1608
  /* Show on short screens (1-column only) */
1609
  @media (max-height: 700px) {
1610
    display: flex;
1611
  }
1612

1613
  /* Hide in 2-column mode - poster goes in section instead */
1614
  @media (min-width: 1200px) and (max-height: 700px) {
1615
    display: none;
1616
  }
1617
}
1618

1619
.poster-overlay-avatar-wrapper {
1620
  position: relative;
1621
  flex-shrink: 0;
1622
}
1623

1624
.poster-overlay-avatar {
1625
  :deep(.ProfileImage__container) {
1626
    width: 28px !important;
1627
    height: 28px !important;
1628
  }
1629
}
1630

1631
.supporter-badge-small {
1632
  position: absolute;
1633
  bottom: -2px;
1634
  right: -2px;
1635
  background: gold;
1636
  color: $color-white;
1637
  width: 14px;
1638
  height: 14px;
1639
  border-radius: 50%;
1640
  display: flex;
1641
  align-items: center;
1642
  justify-content: center;
1643
  border: 1px solid $color-white;
1644
  font-size: 0.45rem;
1645
}
1646

1647
.poster-overlay-info {
1648
  display: flex;
1649
  flex-direction: column;
1650
  min-width: 0;
1651
}
1652

1653
.poster-overlay-name {
1654
  font-size: 0.75rem;
1655
  font-weight: 600;
1656
  white-space: nowrap;
1657
  overflow: hidden;
1658
  text-overflow: ellipsis;
1659
}
1660

1661
.poster-overlay-stats {
1662
  display: flex;
1663
  gap: 0.5rem;
1664
  font-size: 0.65rem;
1665
  color: $color-gray--dark;
1666
}
1667

1668
.poster-overlay-stat {
1669
  display: flex;
1670
  align-items: center;
1671
  gap: 0.15rem;
1672
}
1673

1674
.poster-overlay-chevron {
1675
  flex-shrink: 0;
1676
  color: $color-gray--dark;
1677
  font-size: 0.9rem;
1678
  margin-left: auto;
1679
}
1680

1681
/* Section header with label on left, ID link on right */
1682
.section-header {
1683
  display: flex;
1684
  align-items: center;
1685
  justify-content: space-between;
1686
  margin-top: 1rem;
1687
  margin-bottom: 0.5rem;
1688
  border-bottom: 1px solid $color-gray-3;
1689
  padding-bottom: 0.25rem;
1690

1691
  /* In modal: add right padding to avoid close button overlap */
1692
  .in-modal & {
1693
    padding-right: 3rem;
1694
  }
1695

1696
  /* POSTED BY header hides on short screens where overlay is shown (1-column only) */
1697
  &--poster {
1698
    @media (max-height: 700px) {
1699
      display: none;
1700
    }
1701

1702
    /* Show in 2-column mode - poster section is always visible there */
1703
    @media (min-width: 1200px) and (max-height: 700px) {
1704
      display: flex;
1705
    }
1706
  }
1707
}
1708

1709
.section-header-text {
1710
  font-size: 0.7rem;
1711
  font-weight: 600;
1712
  color: $color-gray--base;
1713
  letter-spacing: 0.1em;
1714
}
1715

1716
.section-header-actions {
1717
  display: flex;
1718
  align-items: center;
1719
  gap: 0.5rem;
1720
}
1721

1722
.action-button {
1723
  display: inline-flex;
1724
  align-items: center;
1725
  gap: 0.25rem;
1726
  padding: 0.25rem 0.5rem;
1727
  border: 1px solid $color-gray--light;
1728
  background: $color-white;
1729
  color: $color-gray--dark;
1730
  font-size: 0.7rem;
1731
  font-weight: 500;
1732
  cursor: pointer;
1733
  transition: all 0.15s ease;
1734

1735
  &:hover {
1736
    background: $color-gray-3;
1737
    border-color: $color-gray--base;
1738
    color: $color-gray--darker;
1739
  }
1740

1741
  &--report {
1742
    color: $color-red--dark;
1743
    border-color: $color-red--light;
1744

1745
    &:hover {
1746
      background: $color-red--lighter;
1747
      border-color: $color-red--dark;
1748
    }
1749
  }
1750
}
1751

1752
.action-button-text {
1753
  display: none;
1754

1755
  @include media-breakpoint-up(md) {
1756
    display: inline;
1757
  }
1758
}
1759

1760
.section-id-link {
1761
  font-size: 0.7rem;
1762
  font-weight: 500;
1763
  color: $color-gray--base;
1764
  text-decoration: none;
1765

1766
  &:hover {
1767
    color: $color-gray--dark;
1768
    text-decoration: underline;
1769
  }
1770
}
1771

1772
/* Poster section wrapper - clickable to open profile modal */
1773
.poster-section-wrapper {
1774
  display: flex;
1775
  align-items: flex-start;
1776
  flex-wrap: wrap;
1777
  gap: 0.5rem;
1778
  padding: 0.75rem 1rem;
1779
  margin-top: 0.5rem;
1780
  text-decoration: none;
1781
  color: inherit;
1782
  background: $color-white;
1783
  border: 1px solid $color-gray--light;
1784
  border-left: 3px solid $colour-info-fg;
1785
  cursor: pointer;
1786

1787
  &:hover {
1788
    text-decoration: none;
1789
    color: inherit;
1790
    background: $color-gray-3;
1791
  }
1792

1793
  /* Hide on short screens where overlay is shown (1-column only) */
1794
  @media (max-height: 700px) {
1795
    display: none;
1796
  }
1797

1798
  /* Show in 2-column mode - poster section is always visible there */
1799
  @media (min-width: 1200px) and (max-height: 700px) {
1800
    display: flex;
1801
  }
1802

1803
  /* Very narrow screens: stack vertically */
1804
  @media (max-width: 320px) {
1805
    flex-direction: column;
1806
    align-items: stretch;
1807
  }
1808
}
1809

1810
/* Poster aboutme - hidden on mobile, shown on tablet */
1811
.poster-aboutme {
1812
  display: none;
1813
  font-size: 0.85rem;
1814
  line-height: 1.5;
1815
  color: $color-gray--darker;
1816
  margin-top: 0.5rem;
1817
  font-style: italic;
1818
  -webkit-line-clamp: 6;
1819
  -webkit-box-orient: vertical;
1820
  overflow: hidden;
1821

1822
  &::before {
1823
    content: '"';
1824
  }
1825

1826
  &::after {
1827
    content: '"';
1828
  }
1829

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

1835
/* Poster ratings - hidden on mobile, shown on tablet */
1836
.poster-ratings {
1837
  display: none !important;
1838
  flex-shrink: 0;
1839

1840
  @include media-breakpoint-up(md) {
1841
    display: flex !important;
1842
  }
1843
}
1844

1845
.poster-avatar-wrapper {
1846
  position: relative;
1847
  flex-shrink: 0;
1848
}
1849

1850
.poster-avatar {
1851
  :deep(.ProfileImage__container) {
1852
    width: 48px !important;
1853
    height: 48px !important;
1854
  }
1855
}
1856

1857
.supporter-badge {
1858
  position: absolute;
1859
  bottom: 0;
1860
  right: 0;
1861
  background: gold;
1862
  color: $color-white;
1863
  width: 20px;
1864
  height: 20px;
1865
  border-radius: 50%;
1866
  display: flex;
1867
  align-items: center;
1868
  justify-content: center;
1869
  border: 2px solid $color-white;
1870
  font-size: 0.6rem;
1871
}
1872

1873
.poster-details {
1874
  flex: 1;
1875
  min-width: 0;
1876
  display: flex;
1877
  flex-direction: column;
1878
  gap: 0.15rem;
1879
  overflow: hidden;
1880
}
1881

1882
.poster-name {
1883
  font-size: 1rem;
1884
  font-weight: 600;
1885
  color: $color-gray--darker;
1886
  white-space: nowrap;
1887
  overflow: hidden;
1888
  text-overflow: ellipsis;
1889
}
1890

1891
.poster-stats {
1892
  display: flex;
1893
  align-items: center;
1894
  flex-wrap: wrap;
1895
  gap: 0.5rem;
1896
  font-size: 0.8rem;
1897
  color: $color-gray--dark;
1898
}
1899

1900
.poster-distance,
1901
.poster-stat {
1902
  display: flex;
1903
  align-items: center;
1904
  gap: 0.2rem;
1905
}
1906

1907
.poster-stat-label {
1908
  display: none;
1909
  margin-left: 0.15rem;
1910

1911
  @include media-breakpoint-up(md) {
1912
    display: inline;
1913
  }
1914
}
1915

1916
.poster-chevron {
1917
  flex-shrink: 0;
1918
  align-self: center;
1919
  color: $color-gray--dark;
1920
  font-size: 1.25rem;
1921
  padding: 0.5rem;
1922
  margin-right: -0.5rem;
1923
}
1924

1925
// Description
1926
.description-section {
1927
  margin-bottom: 1rem;
1928
}
1929

1930
.description-content {
1931
  background: $color-white;
1932
  border: 1px solid $color-gray--light;
1933
  border-left: 3px solid $color-green--darker;
1934
  padding: 1rem;
1935
  font-size: 1rem;
1936
  line-height: 1.7;
1937
  color: $color-gray--darker;
1938

1939
  /* Ensure at least 2 lines visible */
1940
  min-height: 3.4em;
1941
}
1942

1943
.app-footer {
1944
  padding: 1rem;
1945
  border-top: 1px solid $color-gray-3;
1946
  background: $color-white;
1947
  flex-shrink: 0;
1948
  display: flex;
1949
  flex-direction: column;
1950
  justify-content: flex-end;
1951

1952
  /* Hide footer in two-column layout (modal/overlay only - reply is inline there) */
1953
  .in-modal &,
1954
  .fullscreen-overlay & {
1955
    @media (min-width: 1200px) and (max-height: 700px) {
1956
      display: none;
1957
    }
1958
  }
1959

1960
  /* Sticky ad adjustment - add bottom padding instead of positioning */
1961
  &.stickyAdRendered {
1962
    padding-bottom: calc(1rem + $sticky-banner-height-mobile);
1963

1964
    @media (min-height: $mobile-tall) {
1965
      padding-bottom: calc(1rem + $sticky-banner-height-mobile-tall);
1966
    }
1967

1968
    @media (min-height: $desktop-tall) {
1969
      padding-bottom: calc(1rem + $sticky-banner-height-desktop-tall);
1970
    }
1971
  }
1972
}
1973

1974
.footer-buttons {
1975
  display: flex;
1976
  gap: 0.75rem;
1977
  width: 100%;
1978
  max-width: 600px;
1979
  margin: 0 auto;
1980

1981
  .cancel-button,
1982
  .reply-button {
1983
    flex: 1;
1984
    width: auto !important;
1985
    display: flex !important;
1986
    justify-content: center;
1987
  }
1988

1989
  /* Mobile: only Reply button visible, full width */
1990
  @media (max-width: 767.98px) {
1991
    .cancel-button {
1992
      display: none !important;
1993
    }
1994

1995
    .reply-button {
1996
      width: 100% !important;
1997
    }
1998
  }
1999
}
2000

2001
/* When only Cancel button is shown (own posts), full width */
2002
.footer-buttons:has(.cancel-button:only-child) .cancel-button {
2003
  flex: 1;
2004
  width: 100% !important;
2005
}
2006

2007
.reply-expanded-section {
2008
  max-height: 70vh;
2009
  overflow-y: auto;
2010
}
2011

2012
.promised-notice {
2013
  text-align: center;
2014
  color: $color-orange--dark;
2015
  font-size: 0.85rem;
2016
  font-weight: 500;
2017
}
2018

2019
// Fullscreen map viewer
2020
.fullscreen-map-viewer {
2021
  position: fixed;
2022
  top: 0;
2023
  left: 0;
2024
  right: 0;
2025
  bottom: 0;
2026
  background: $color-gray--lighter;
2027
  z-index: 10000;
2028
  display: flex;
2029
  flex-direction: column;
2030
}
2031

2032
.map-back-button {
2033
  position: absolute;
2034
  top: env(safe-area-inset-top, 0);
2035
  left: 0;
2036
  margin: 1rem;
2037
  width: 44px;
2038
  height: 44px;
2039
  border-radius: 50%;
2040
  background: $color-white-opacity-95;
2041
  border: none;
2042
  color: $color-gray--darker;
2043
  display: flex;
2044
  align-items: center;
2045
  justify-content: center;
2046
  cursor: pointer;
2047
  z-index: 10001;
2048
  font-size: 1.25rem;
2049
  box-shadow: 0 2px 8px $color-black-opacity-20;
2050

2051
  &:active {
2052
    background: $color-white;
2053
  }
2054
}
2055

2056
.fullscreen-map {
2057
  flex: 1;
2058
  width: 100%;
2059
  height: 100% !important;
2060

2061
  :deep(.leaflet-container) {
2062
    height: 100% !important;
2063
  }
2064
}
2065

2066
.map-hint {
2067
  position: absolute;
2068
  bottom: env(safe-area-inset-bottom, 0);
2069
  left: 0;
2070
  right: 0;
2071
  margin-bottom: 1rem;
2072
  padding: 0.5rem 1rem;
2073
  background: $color-white-opacity-90;
2074
  color: $color-gray--dark;
2075
  font-size: 0.85rem;
2076
  text-align: center;
2077
  margin-left: 1rem;
2078
  margin-right: 1rem;
2079
  border-radius: 8px;
2080
  box-shadow: 0 2px 8px $color-black-opacity-10;
2081
}
2082
</style>
2083

2084
<!-- Unscoped styles for Ken Burns - scoped :deep() doesn't penetrate NuxtPicture -->
2085
<style lang="scss">
2086
@import 'bootstrap/scss/functions';
2087
@import 'bootstrap/scss/variables';
2088
@import 'bootstrap/scss/mixins/_breakpoints';
2089

2090
/* Ken Burns effect - slow pan and zoom for ~10s then stop centered, mobile/tablet only */
2091
@keyframes kenburns {
2092
  0% {
2093
    transform: scale(1.15) translate(3%, 3%);
2094
  }
2095
  50% {
2096
    transform: scale(1.15) translate(-3%, -3%);
2097
  }
2098
  100% {
2099
    transform: scale(1) translate(0%, 0%);
2100
  }
2101
}
2102

2103
.photo-container.ken-burns img {
2104
  animation: kenburns 10s ease-in-out forwards;
2105
  will-change: transform;
2106
  transform-origin: center center;
2107

2108
  /* Disable on desktop (lg and up) */
2109
  @include media-breakpoint-up(lg) {
2110
    animation: none;
2111
    transform: none;
2112
  }
2113
}
2114

2115
@media (prefers-reduced-motion: reduce) {
2116
  .photo-container.ken-burns img {
2117
    animation: none !important;
2118
  }
2119
}
2120
</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