1
0
Fork 0

Alrighty, this is media-view layout

This commit is contained in:
Lim Chee Aun 2023-10-29 21:41:03 +08:00
parent 35f7cae01f
commit b40bbb32c2
11 changed files with 440 additions and 53 deletions

View file

@ -209,6 +209,60 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.timeline {
margin: 0 auto;
padding: 0;
&.timeline-media {
--grid-gap: 8px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-rows: fit-content;
gap: var(--grid-gap);
padding: var(--grid-gap);
&:not(#columns &) {
background-color: var(--bg-faded-color);
}
@media (min-width: 40em) {
&:not(#columns &) {
--grid-gap: 16px;
grid-template-columns: 1fr 1fr 1fr;
@media (min-width: 40em) {
width: 95vw;
max-width: calc(320px * 3.3);
transform: translateX(calc(-50% + var(--main-width) / 2));
}
}
#columns & {
padding-inline: 0;
}
}
li {
padding: 0 !important;
margin: 0 !important;
border: 0 !important;
overflow: visible !important;
background-color: transparent !important;
box-shadow: none !important;
}
@supports (grid-template-rows: masonry) {
grid-template-rows: masonry;
masonry-auto-flow: pack;
.media-post a {
aspect-ratio: revert !important;
video,
img,
audio {
min-height: 88px; /* for extreme dimensions */
}
}
}
}
}
.timeline.grow {
/* min-height: 100vh;
@ -1678,6 +1732,24 @@ body > .szh-menu-container {
opacity: 1;
}
.szh-menu
.szh-menu__item--type-checkbox:not(.szh-menu__item--disabled):not(
.szh-menu__item--hover
) {
.icon {
opacity: 0.15;
}
&.szh-menu__item--checked {
color: var(--link-color);
.icon {
opacity: 1;
color: inherit;
}
}
}
.szh-menu .menu-wrap {
display: flex;
flex-wrap: wrap;

View file

@ -25,6 +25,7 @@ body.cloak,
}
.status :is(img, video, audio),
.media-post .media,
.avatar,
.emoji,
.header-banner {

View file

@ -102,6 +102,7 @@ export const ICONS = {
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: () => import('@iconify-icons/mingcute/calendar-month-line'),
media: () => import('@iconify-icons/mingcute/photo-album-line'),
};
function Icon({

View file

@ -0,0 +1,87 @@
.media-post {
--item-radius: 16px;
position: relative;
animation: appear-smooth 1s ease-out;
&:is(.filtered, .has-spoiler) :is(img, video) {
filter: blur(32px);
image-rendering: crisp-edges;
image-rendering: pixelated;
animation: none !important;
}
&.filtered[data-filtered-text]:before,
&.has-spoiler[data-spoiler-text]:before {
pointer-events: none;
content: attr(data-spoiler-text);
position: absolute;
top: 0;
left: 0;
z-index: 1;
background-color: var(--bg-blur-color);
margin: 8px;
padding: 4px 6px;
border-radius: calc(var(--item-radius) / 2);
font-size: 90%;
border: var(--hairline-width) dashed var(--bg-color);
> * {
pointer-events: none;
}
}
.media {
border-radius: var(--item-radius);
overflow: hidden;
position: relative;
display: block;
aspect-ratio: 1 !important;
&:before {
position: absolute;
inset: 0;
content: '';
border: 1px solid var(--outline-color);
border-radius: inherit;
}
&:not(.media-audio) {
background-color: var(--average-color, var(--media-bg-color));
}
@media (hover: hover) {
&:hover {
--drop-shadow: var(--drop-shadow-color);
box-shadow: 0 8px 16px -4px var(--drop-shadow),
0 4px 8px var(--drop-shadow);
@media (prefers-color-scheme: dark) {
--drop-shadow: var(--link-color);
}
}
}
&:active:not(:has(button:active)) {
box-shadow: none;
filter: brightness(0.8);
transform: scale(0.99);
}
video,
img,
audio {
border-radius: 16px;
/* object-fit: scale-down; */
object-fit: cover;
width: 100%;
height: 100%;
vertical-align: top;
}
&:is(:hover, :focus) img {
/* Less delay here to make it feel more responsive */
animation: position-object 5s ease-in-out 0.3s 5;
animation-duration: var(--anim-duration, 5s);
}
}
}

View file

@ -0,0 +1,126 @@
import './media-post.css';
import { memo } from 'preact/compat';
import { useSnapshot } from 'valtio';
import states, { statusKey } from '../utils/states';
import Media from './media';
function MediaPost({
class: className,
statusID,
status,
instance,
parent,
allowFilters,
onMediaClick,
}) {
let sKey = statusKey(statusID, instance);
const snapStates = useSnapshot(states);
if (!status) {
status = snapStates.statuses[sKey] || snapStates.statuses[statusID];
sKey = statusKey(status?.id, instance);
}
if (!status) {
return null;
}
const {
account: {
acct,
avatar,
avatarStatic,
id: accountId,
url: accountURL,
displayName,
username,
emojis: accountEmojis,
bot,
group,
},
id,
repliesCount,
reblogged,
reblogsCount,
favourited,
favouritesCount,
bookmarked,
poll,
muted,
sensitive,
spoilerText,
visibility, // public, unlisted, private, direct
language,
editedAt,
filtered,
card,
createdAt,
inReplyToId,
inReplyToAccountId,
content,
mentions,
mediaAttachments,
reblog,
uri,
url,
emojis,
// Non-API props
_deleted,
_pinned,
_filtered,
} = status;
if (!mediaAttachments?.length) {
return null;
}
const debugHover = (e) => {
if (e.shiftKey) {
console.log({
...status,
});
}
};
console.debug('RENDER Media post', id, status?.account.displayName);
// const readingExpandSpoilers = useMemo(() => {
// const prefs = store.account.get('preferences') || {};
// return !!prefs['reading:expand:spoilers'];
// }, []);
const hasSpoiler = spoilerText || sensitive;
const Parent = parent || 'div';
return mediaAttachments.map((media, i) => {
const mediaKey = `${sKey}-${media.id}`;
return (
<Parent
onMouseEnter={debugHover}
key={mediaKey}
data-spoiler-text={
spoilerText || (sensitive ? 'Sensitive media' : undefined)
}
data-filtered-text={_filtered ? 'Filtered' : undefined}
class={`
media-post
${allowFilters && _filtered ? 'filtered' : ''}
${hasSpoiler ? 'has-spoiler' : ''}
`}
>
<Media
class={className}
media={media}
lang={language}
to={`/${instance}/s/${id}?media-only=${i + 1}`}
onClick={
onMediaClick ? (e) => onMediaClick(e, i, media, status) : undefined
}
/>
</Parent>
);
});
}
export default memo(MediaPost);

View file

@ -62,6 +62,7 @@ export const isMediaCaptionLong = mem((caption) =>
);
function Media({
class: className = '',
media,
to,
lang,
@ -170,6 +171,9 @@ function Media({
const maxAspectHeight =
window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33);
const maxHeight = orientation === 'portrait' ? 0 : 160;
const averageColorStyle = {
'--average-color': rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
};
const mediaStyles =
width && height
? {
@ -180,8 +184,11 @@ function Media({
(width / height) * Math.max(maxHeight, maxAspectHeight)
}px`,
aspectRatio: `${width} / ${height}`,
...averageColorStyle,
}
: {};
: {
...averageColorStyle,
};
const longDesc = isMediaCaptionLong(description);
const showInlineDesc =
@ -233,7 +240,7 @@ function Media({
<Figure>
<Parent
ref={parentRef}
class={`media media-image`}
class={`media media-image ${className}`}
onClick={onClick}
data-orientation={orientation}
data-has-alt={!showInlineDesc}
@ -244,6 +251,7 @@ function Media({
backgroundSize: imageSmallerThanParent
? `${width}px ${height}px`
: undefined,
...averageColorStyle,
}
: mediaStyles
}
@ -341,11 +349,13 @@ function Media({
return (
<Figure>
<Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${
class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : ''
}`}
data-orientation={orientation}
data-formatted-duration={formattedDuration}
data-formatted-duration={
!showOriginal ? formattedDuration : undefined
}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
data-has-alt={!showInlineDesc}
// style={{
@ -448,8 +458,10 @@ function Media({
return (
<Figure>
<Parent
class="media media-audio"
data-formatted-duration={formattedDuration}
class={`media media-audio ${className}`}
data-formatted-duration={
!showOriginal ? formattedDuration : undefined
}
data-has-alt={!showInlineDesc}
onClick={onClick}
style={!showOriginal && mediaStyles}

View file

@ -104,6 +104,11 @@ const TYPE_PARAMS = {
placeholder: 'e.g. PixelArt (Max 5, space-separated)',
pattern: '[^#]+',
},
{
text: 'Media only',
name: 'media',
type: 'checkbox',
},
{
text: 'Instance',
name: 'instance',
@ -186,8 +191,10 @@ export const SHORTCUTS_META = {
id: 'hashtag',
title: ({ hashtag }) => hashtag,
subtitle: ({ instance }) => instance || api().instance,
path: ({ hashtag, instance }) =>
`${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}`,
path: ({ hashtag, instance, media }) =>
`${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}${
media ? '?media=1' : ''
}`,
icon: 'hashtag',
},
};

View file

@ -846,7 +846,7 @@
object-fit: cover;
vertical-align: middle;
}
.status .media {
.media {
cursor: pointer;
&[data-has-alt] {
@ -885,7 +885,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: relative;
background-clip: padding-box;
}
.status :is(.media-video, .media-audio) .media-play {
:is(.media-video, .media-audio) .media-play {
pointer-events: none;
width: 44px;
height: 44px;
@ -902,10 +902,10 @@ body:has(#modal-container .carousel) .status .media img:hover {
border-radius: 70px;
transition: transform 0.2s ease-in-out;
}
.status :is(.media-video, .media-audio):hover:not(:active) .media-play {
:is(.media-video, .media-audio):hover:not(:active) .media-play {
transform: translate(-50%, -50%) scale(1.1);
}
.status :is(.media-video, .media-audio)[data-formatted-duration]:after {
:is(.media-video, .media-audio)[data-formatted-duration]:after {
font-size: 12px;
pointer-events: none;
content: attr(data-formatted-duration);
@ -918,10 +918,10 @@ body:has(#modal-container .carousel) .status .media img:hover {
border-radius: 4px;
padding: 0 4px;
}
.status .media-audio[data-formatted-duration]:after {
.media-audio[data-formatted-duration]:after {
content: '♬ ' attr(data-formatted-duration);
}
.status .media-gif[data-label]:not(:hover):after {
.media-gif[data-label]:not(:hover):after {
font-size: 12px;
font-weight: bold;
pointer-events: none;
@ -953,12 +953,12 @@ body:has(#modal-container .carousel) .status .media img:hover {
.status .media-audio audio {
width: 100%;
} */
.status .media-audio {
.media-audio {
width: 100%;
height: 100%;
background-image: radial-gradient(
circle at center center,
var(--bg-color),
transparent,
var(--bg-faded-color)
),
repeating-radial-gradient(

View file

@ -13,6 +13,8 @@ import useScroll from '../utils/useScroll';
import Icon from './icon';
import Link from './link';
import Media from './media';
import MediaPost from './media-post';
import NavMenu from './nav-menu';
import Status from './status';
@ -39,6 +41,7 @@ function Timeline({
timelineStart,
allowFilters,
refresh,
view,
}) {
const snapStates = useSnapshot(states);
const [items, setItems] = useState([]);
@ -50,6 +53,7 @@ function Timeline({
console.debug('RENDER Timeline', id, refresh);
const allowGrouping = view !== 'media';
const loadItems = useDebouncedCallback(
(firstLoad) => {
setShowNew(false);
@ -59,10 +63,12 @@ function Timeline({
try {
let { done, value } = await fetchItems(firstLoad);
if (Array.isArray(value)) {
if (boostsCarousel) {
value = groupBoosts(value);
if (allowGrouping) {
if (boostsCarousel) {
value = groupBoosts(value);
}
value = groupContext(value);
}
value = groupContext(value);
console.log(value);
if (firstLoad) {
setItems(value);
@ -210,6 +216,14 @@ function Timeline({
}
}, [nearReachEnd, showMore]);
const prevView = useRef(view);
useEffect(() => {
if (prevView.current !== view) {
prevView.current = view;
setItems([]);
}
}, [view]);
const loadOrCheckUpdates = useCallback(
async ({ disableIdleCheck = false } = {}) => {
console.log('✨ Load or check updates', {
@ -346,7 +360,7 @@ function Timeline({
)}
{!!items.length ? (
<>
<ul class="timeline">
<ul class={`timeline ${view ? `timeline-${view}` : ''}`}>
{items.map((status) => (
<TimelineItem
status={status}
@ -354,26 +368,29 @@ function Timeline({
useItemID={useItemID}
allowFilters={allowFilters}
key={status.id + status?._pinned}
view={view}
/>
))}
{showMore && uiState === 'loading' && (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
{showMore &&
uiState === 'loading' &&
(view === 'media' ? null : (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
))}
</ul>
{uiState === 'default' &&
(showMore ? (
@ -399,11 +416,19 @@ function Timeline({
</>
) : uiState === 'loading' ? (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
{Array.from({ length: 5 }).map((_, i) =>
view === 'media' ? (
<div
style={{
height: '50vh',
}}
/>
) : (
<li key={i}>
<Status skeleton />
</li>
),
)}
</ul>
) : (
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
@ -426,7 +451,7 @@ function Timeline({
);
}
function TimelineItem({ status, instance, useItemID, allowFilters }) {
function TimelineItem({ status, instance, useItemID, allowFilters, view }) {
const { id: statusID, reblog, items, type, _pinned } = status;
const actualStatusID = reblog?.id || statusID;
const url = instance
@ -531,8 +556,33 @@ function TimelineItem({ status, instance, useItemID, allowFilters }) {
);
});
}
const itemKey = `timeline-${statusID + _pinned}`;
if (view === 'media') {
return useItemID ? (
<MediaPost
class="timeline-item"
parent="li"
key={itemKey}
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
/>
) : (
<MediaPost
class="timeline-item"
parent="li"
key={itemKey}
status={status}
instance={instance}
allowFilters={allowFilters}
/>
);
}
return (
<li key={`timeline-${statusID + _pinned}`}>
<li key={itemKey}>
<Link class="status-link timeline-item" to={url}>
{useItemID ? (
<Status

View file

@ -414,6 +414,7 @@ function AccountStatuses() {
errorText="Unable to load posts"
fetchItems={fetchAccountStatuses}
useItemID
view={media ? 'media' : undefined}
boostsCarousel={snapStates.settings.boostsCarousel}
timelineStart={TimelineStart}
refresh={[

View file

@ -2,10 +2,11 @@ import {
FocusableItem,
MenuDivider,
MenuGroup,
MenuHeader,
MenuItem,
} from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import Icon from '../components/icon';
import Menu2 from '../components/menu2';
@ -25,13 +26,16 @@ const LIMIT = 20;
const TAGS_LIMIT_PER_MODE = 4;
const TOTAL_TAGS_LIMIT = TAGS_LIMIT_PER_MODE + 1;
function Hashtags({ columnMode, ...props }) {
function Hashtags({ media: mediaView, columnMode, ...props }) {
// const navigate = useNavigate();
let { hashtag, ...params } = columnMode ? {} : useParams();
if (props.hashtag) hashtag = props.hashtag;
let hashtags = hashtag.trim().split(/[\s+]+/);
hashtags.sort();
hashtag = hashtags[0];
const [searchParams, setSearchParams] = useSearchParams();
const media = mediaView || !!searchParams.get('media');
const linkParams = media ? '?media=1' : '';
const { masto, instance, authenticated } = api({
instance: props?.instance || params.instance,
@ -60,6 +64,7 @@ function Hashtags({ columnMode, ...props }) {
limit: LIMIT,
any: hashtags.slice(1),
maxId: firstLoad ? undefined : maxID.current,
onlyMedia: media,
})
.next();
const { value } = results;
@ -69,7 +74,9 @@ function Hashtags({ columnMode, ...props }) {
}
value.forEach((item) => {
saveStatus(item, instance);
saveStatus(item, instance, {
skipThreading: media, // If media view, no need to form threads
});
});
maxID.current = value[value.length - 1].id;
@ -136,6 +143,8 @@ function Hashtags({ columnMode, ...props }) {
fetchItems={fetchHashtags}
checkForUpdates={checkForUpdates}
useItemID
view={media ? 'media' : undefined}
refresh={media}
headerEnd={
<Menu2
portal
@ -209,6 +218,23 @@ function Hashtags({ columnMode, ...props }) {
<MenuDivider />
</>
)}
<MenuHeader className="plain">Filters</MenuHeader>
<MenuItem
type="checkbox"
checked={!!media}
onClick={() => {
if (media) {
searchParams.delete('media');
} else {
searchParams.set('media', '1');
}
setSearchParams(searchParams);
}}
>
<Icon icon="check-circle" />{' '}
<span class="menu-grow">Media only</span>
</MenuItem>
<MenuDivider />
<FocusableItem className="menu-field" disabled={reachLimit}>
{({ ref }) => (
<form
@ -231,7 +257,7 @@ function Hashtags({ columnMode, ...props }) {
// );
location.hash = instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`;
: `/t/${hashtags.join('+')}${linkParams}`;
}
}}
>
@ -267,8 +293,8 @@ function Hashtags({ columnMode, ...props }) {
// : `/t/${hashtags.join('+')}`,
// );
location.hash = instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`;
? `/${instance}/t/${hashtags.join('+')}${linkParams}`
: `/t/${hashtags.join('+')}${linkParams}`;
}}
>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
@ -287,6 +313,7 @@ function Hashtags({ columnMode, ...props }) {
type: 'hashtag',
hashtag: hashtags.join(' '),
instance,
media: media ? 'on' : undefined,
};
// Check if already exists
const exists = states.shortcuts.some(
@ -300,7 +327,8 @@ function Hashtags({ columnMode, ...props }) {
.split(/[\s+]+/)
.sort()
.join(' ') &&
(s.instance ? s.instance === shortcut.instance : true),
(s.instance ? s.instance === shortcut.instance : true) &&
(s.media ? !!s.media === !!shortcut.media : true),
);
if (exists) {
alert('This shortcut already exists');
@ -324,7 +352,9 @@ function Hashtags({ columnMode, ...props }) {
if (newInstance) {
newInstance = newInstance.toLowerCase().trim();
// navigate(`/${newInstance}/t/${hashtags.join('+')}`);
location.hash = `/${newInstance}/t/${hashtags.join('+')}`;
location.hash = `/${newInstance}/t/${hashtags.join(
'+',
)}${linkParams}`;
}
}}
>