ENH - preview Feather post images

This commit is contained in:
smx.pusha
2026-08-10 07:43:13 +02:00
parent 88deacb892
commit 94f20f499b
4 changed files with 157 additions and 9 deletions
@@ -17,6 +17,7 @@ import type { FeatherPost } from '@/types/feather'
const props = defineProps<{ post: FeatherPost }>()
defineEmits<{
follow: [post: FeatherPost]
media: [post: FeatherPost, index: number]
menu: [post: FeatherPost]
open: [post: FeatherPost]
profile: [profileId: number]
@@ -127,12 +128,20 @@ function relativeTime(timestamp: number): string {
class="feather-media"
:class="`feather-media--${Math.min(post.media.length, 4)}`"
>
<img
v-for="item in post.media"
<button
v-for="(item, index) in post.media"
:key="item.id"
:src="item.url"
alt=""
/>
type="button"
class="feather-media__item"
:aria-label="
phone.t('Apps.feather.openImage', {
number: String(index + 1),
})
"
@click.stop="$emit('media', post, index)"
>
<img :src="item.url" alt="" />
</button>
</div>
<footer class="feather-actions">
@@ -307,7 +316,17 @@ function relativeTime(timestamp: number): string {
border: 0.5px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 14px;
}
.feather-media img {
.feather-media__item {
min-width: 0;
min-height: 105px;
overflow: hidden;
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
}
.feather-media__item img {
display: block;
width: 100%;
height: 100%;
min-height: 105px;
@@ -318,7 +337,7 @@ function relativeTime(timestamp: number): string {
.feather-media--4 {
grid-template-columns: repeat(2, 1fr);
}
.feather-media--3 img:first-child {
.feather-media--3 .feather-media__item:first-child {
grid-row: span 2;
}
.feather-actions {
+5
View File
@@ -550,6 +550,11 @@ const defaultLocales: LocaleTree = {
bookmarks: 'Bookmarks',
noBookmarks: 'No saved posts yet',
noBookmarksBody: 'Posts you bookmark will appear here.',
imagePreview: 'Image preview',
openImage: 'Open image {number}',
postImage: 'Post image {number}',
previousImage: 'Previous image',
nextImage: 'Next image',
delete: 'Delete',
report: 'Report',
block: 'Block @{handle}',
+125 -1
View File
@@ -7,6 +7,8 @@ import {
Camera,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Eye,
EyeOff,
Feather,
@@ -62,7 +64,7 @@ import { useFeatherStore } from '@/stores/feather'
import type { FeatherConnectionMode } from '@/stores/feather'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { FeatherPost, FeatherProfile } from '@/types/feather'
import type { FeatherMedia, FeatherPost, FeatherProfile } from '@/types/feather'
import {
filterMailAddressInput,
MAIL_ADDRESS_INPUT_MAX_LENGTH,
@@ -116,6 +118,7 @@ const menuPost = ref<FeatherPost | null>(null)
const reportOpen = ref(false)
const reportReason = ref('spam')
const reportDetails = ref('')
const mediaPreview = ref<{ index: number; items: FeatherMedia[] } | null>(null)
const onboarding = ref({ bio: '', displayName: '', handle: '' })
const editing = ref({ bio: '', displayName: '' })
const authMode = ref<'login' | 'register'>('login')
@@ -149,6 +152,10 @@ const displayedProfilePosts = computed(() => {
return feather.profilePosts.filter((post) => post.media.length > 0)
return feather.profilePosts.filter((post) => !post.reply_to_id)
})
const previewMedia = computed(() => {
if (!mediaPreview.value) return null
return mediaPreview.value.items[mediaPreview.value.index] ?? null
})
const networkSections = computed(() => {
const sections: Array<{
key: 'results' | 'suggestions'
@@ -241,6 +248,28 @@ function t(path: string, params?: Record<string, string>): string {
return phone.t(`Apps.feather.${path}`, params)
}
function openMediaPreview(post: FeatherPost, index: number): void {
mediaPreview.value = { index, items: post.media }
}
function closeMediaPreview(): void {
mediaPreview.value = null
}
function moveMediaPreview(direction: number): void {
if (!mediaPreview.value || mediaPreview.value.items.length < 2) return
mediaPreview.value.index =
(mediaPreview.value.index + direction + mediaPreview.value.items.length) %
mediaPreview.value.items.length
}
function handleMediaPreviewKeydown(event: KeyboardEvent): void {
if (!mediaPreview.value) return
if (event.key === 'Escape') closeMediaPreview()
if (event.key === 'ArrowLeft') moveMediaPreview(-1)
if (event.key === 'ArrowRight') moveMediaPreview(1)
}
function toast(path: string): void {
feedback.value = t(path)
window.setTimeout(() => {
@@ -651,9 +680,11 @@ watch(
onBeforeUnmount(() => {
if (exploreSearchTimer !== undefined) window.clearTimeout(exploreSearchTimer)
if (networkSearchTimer !== undefined) window.clearTimeout(networkSearchTimer)
window.removeEventListener('keydown', handleMediaPreviewKeydown)
})
onMounted(async () => {
window.addEventListener('keydown', handleMediaPreviewKeydown)
const selection =
messageMedia.consumeMany<ComposerContext>('feather:composer')
if (selection) {
@@ -1217,6 +1248,7 @@ onMounted(async () => {
<FeatherPostCard
:post="feather.thread.post"
@follow="feather.followPost"
@media="openMediaPreview"
@menu="menuPost = $event"
@open="() => undefined"
@profile="openProfile"
@@ -1300,6 +1332,7 @@ onMounted(async () => {
:key="post.id"
:post="post"
@follow="feather.followPost"
@media="openMediaPreview"
@menu="menuPost = $event"
@open="openThread"
@profile="openProfile"
@@ -1456,6 +1489,7 @@ onMounted(async () => {
:key="post.id"
:post="post"
@follow="feather.followPost"
@media="openMediaPreview"
@menu="menuPost = $event"
@open="openThread"
@profile="openProfile"
@@ -1800,6 +1834,7 @@ onMounted(async () => {
:key="post.id"
:post="post"
@follow="feather.followPost"
@media="openMediaPreview"
@menu="menuPost = $event"
@open="openThread"
@profile="openProfile"
@@ -2048,6 +2083,44 @@ onMounted(async () => {
</kBlock>
</kSheet>
<div
v-if="mediaPreview && previewMedia"
class="feather-media-preview"
role="dialog"
aria-modal="true"
:aria-label="t('imagePreview')"
@click="closeMediaPreview"
>
<img
:src="previewMedia.url"
:alt="t('postImage', { number: String(mediaPreview.index + 1) })"
@click.stop
/>
<template v-if="mediaPreview.items.length > 1">
<kButton
clear
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--left"
:aria-label="t('previousImage')"
@click.stop="moveMediaPreview(-1)"
>
<ChevronLeft :size="27" />
</kButton>
<kButton
clear
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--right"
:aria-label="t('nextImage')"
@click.stop="moveMediaPreview(1)"
>
<ChevronRight :size="27" />
</kButton>
<span class="feather-media-preview__count">
{{ mediaPreview.index + 1 }} / {{ mediaPreview.items.length }}
</span>
</template>
</div>
<kToast :opened="Boolean(feedback)">{{ feedback }}</kToast>
</kPage>
</template>
@@ -2059,6 +2132,57 @@ onMounted(async () => {
background: #fff;
color: #111923;
}
.feather-media-preview {
position: absolute;
z-index: 120;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 64px 12px 42px;
background: rgb(0 0 0 / 92%);
cursor: zoom-out;
}
.feather-media-preview > img {
display: block;
max-width: 100%;
max-height: 100%;
border-radius: 12px;
object-fit: contain;
box-shadow: 0 18px 50px rgb(0 0 0 / 55%);
cursor: default;
}
.feather-media-preview__arrow {
--k-button-bg-color: rgb(22 27 34 / 78%);
--k-button-text-color: #fff;
position: absolute;
top: 50%;
width: 42px;
height: 42px;
border: 1px solid rgb(255 255 255 / 18%);
backdrop-filter: blur(14px);
transform: translateY(-50%);
}
.feather-media-preview__arrow--left {
left: 14px;
}
.feather-media-preview__arrow--right {
right: 14px;
}
.feather-media-preview__count {
position: absolute;
bottom: 48px;
left: 50%;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 999px;
padding: 5px 10px;
color: #fff;
background: rgb(22 27 34 / 78%);
backdrop-filter: blur(14px);
font-size: 11px;
font-weight: 700;
transform: translateX(-50%);
}
:global(.dark) .feather-app {
background: #090d12;
color: #f4f7fa;
+1 -1
View File
@@ -211,7 +211,7 @@ Locales["en"] = {
trendKinds = { explore = "Trending in Los Santos", trending = "Popular right now", news = "News · Trending", sports = "Sports · Trending", entertainment = "Entertainment · Trending" },
people = "Who to follow", posts = "Posts", followers = "Followers", followingCount = "Following", removeConnection = "Remove", followerRemoved = "Follower removed.", followingRemoved = "Account unfollowed.", noConnections = "Nobody here yet", noConnectionsBody = "Profiles will appear here as connections are made.", follow = "Follow", unfollow = "Following",
editProfile = "Edit profile", saveProfile = "Save", joined = "Joined Feather", joinedDate = "Joined August 2026", shareProfile = "Share profile", profileCopied = "Profile handle copied.", postCopied = "Post copied.", verified = "Verified profile", noBio = "No bio yet.", showMore = "Show more",
replies = "Replies", noReplies = "No replies yet. Start the conversation.", likes = "Likes", bookmarks = "Bookmarks", noBookmarks = "No saved posts yet", noBookmarksBody = "Posts you bookmark will appear here.", delete = "Delete", report = "Report", block = "Block @{handle}",
replies = "Replies", noReplies = "No replies yet. Start the conversation.", likes = "Likes", bookmarks = "Bookmarks", noBookmarks = "No saved posts yet", noBookmarksBody = "Posts you bookmark will appear here.", imagePreview = "Image preview", openImage = "Open image {number}", postImage = "Post image {number}", previousImage = "Previous image", nextImage = "Next image", delete = "Delete", report = "Report", block = "Block @{handle}",
reportTitle = "Report feather", reportBody = "Tell us why this feather should be reviewed.", reportSubmit = "Submit report", reported = "Report submitted.", blocked = "Profile blocked.", deleted = "Feather deleted.",
reasons = { spam = "Spam", harassment = "Harassment", dangerous = "Dangerous content", illegal = "Illegal content", other = "Other" },
noActivity = "No activity yet", activityBody = "Likes, replies and new followers will appear here.",