[Glitch] Add new overview landing page setting

Port 07d099cbf7b646b7dc715c3bfe3a8ea453e9eafb to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Eugen Rochko 2026-05-26 14:36:54 +02:00 committed by Claire
parent 2fb6a268b1
commit 1de3b951eb
10 changed files with 464 additions and 11 deletions

View File

@ -16,19 +16,33 @@ export const fetchServer = createDataLoadingThunk(
dispatch(importFetchedAccount(instance.contact.account));
}
},
{
condition: (_, { getState }) => !getState().server.server.isLoading,
},
);
export const fetchExtendedDescription = createDataLoadingThunk(
'server/extended_description',
() => apiGetExtendedDescription(),
{
condition: (_, { getState }) =>
!getState().server.extendedDescription.isLoading,
},
);
export const fetchServerTranslationLanguages = createDataLoadingThunk(
'server/translation_languages',
() => apiGetTranslationLanguages(),
{
condition: (_, { getState }) =>
!getState().server.translationLanguages.isLoading,
},
);
export const fetchDomainBlocks = createDataLoadingThunk(
'server/domain_blocks',
() => apiGetDomainBlocks(),
{
condition: (_, { getState }) => !getState().server.domainBlocks.isLoading,
},
);

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchExtendedDescription } from 'flavours/glitch/actions/server';
import { Account } from 'flavours/glitch/components/account';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import classes from './styles.module.scss';
const Placeholder = () => (
<div className={classes.placeholder}>
<Skeleton width='100%' />
<Skeleton width='100%' />
<Skeleton width='100%' />
</div>
);
export const About = () => {
const dispatch = useAppDispatch();
const server = useAppSelector((state) => state.server.server);
const extendedDescription = useAppSelector(
(state) => state.server.extendedDescription,
);
const accountId = server.item?.contact.account?.id ?? '';
const isLoading = extendedDescription.isLoading;
const hasContent = (extendedDescription.item?.content.length ?? 0) > 0;
const content = extendedDescription.item?.content ?? '';
useEffect(() => {
void dispatch(fetchExtendedDescription());
}, [dispatch]);
return (
<>
<div className={classes.block}>
<h2>
<FormattedMessage
id='custom_homepage.administered_by'
defaultMessage='Administered by'
/>
</h2>
<Account id={accountId} size={36} minimal />
</div>
<div className={classes.block}>
<h2>
<FormattedMessage
id='custom_homepage.about_this_server'
defaultMessage='About this server'
/>
</h2>
{isLoading ? (
<Placeholder />
) : hasContent ? (
<div
className='prose'
dangerouslySetInnerHTML={{ __html: content }}
/>
) : (
<div className='prose'>
<p>
<FormattedMessage
id='about.not_available'
defaultMessage='This information has not been made available on this server.'
/>
</p>
</div>
)}
</div>
</>
);
};

View File

@ -0,0 +1,39 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { fetchServer } from 'flavours/glitch/actions/server';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import classes from '../styles.module.scss';
export const Footer = () => {
const dispatch = useAppDispatch();
const server = useAppSelector((state) => state.server.server);
const email = server.item?.contact.email ?? '';
useEffect(() => {
void dispatch(fetchServer());
}, [dispatch]);
return (
<footer className={classes.minimalFooter}>
<div className={classes.contact}>
<FormattedMessage
id='custom_homepage.contact'
defaultMessage='Contact:'
/>
<a href={`mailto:${email}`}>{email}</a>
</div>
<Link to='/privacy-policy' rel='privacy-policy'>
<FormattedMessage
id='footer.privacy_policy'
defaultMessage='Privacy policy'
/>
</Link>
</footer>
);
};

View File

@ -0,0 +1,25 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { domain, sso_redirect } from 'flavours/glitch/initial_state';
import classes from '../styles.module.scss';
export const Header = () => (
<div className={classes.minimalHeader}>
<div className={classes.leftSide}>
<Link to='/overview'>{domain}</Link>
</div>
<div className={classes.rightSide}>
<a
href={sso_redirect ?? '/auth/sign_in'}
data-method={sso_redirect ? 'post' : undefined}
className='button button-secondary'
>
<FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' />
</a>
</div>
</div>
);

View File

@ -0,0 +1,71 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Route, Switch, useRouteMatch } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import { fetchServer } from 'flavours/glitch/actions/server';
import { ServerHeroImage } from 'flavours/glitch/components/server_hero_image';
import { TabLink, TabList } from 'flavours/glitch/components/tab_list';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { About } from './about';
import { LatestActivity } from './latest_activity';
import classes from './styles.module.scss';
export const CustomHomepage: React.FC = () => {
const dispatch = useAppDispatch();
const server = useAppSelector((state) => state.server.server);
const { path } = useRouteMatch();
useEffect(() => {
void dispatch(fetchServer());
}, [dispatch]);
return (
<div className={classes.page}>
<ServerHeroImage
alt={server.item?.thumbnail.description ?? ''}
blurhash={server.item?.thumbnail.blurhash ?? ''}
src={server.item?.thumbnail.url ?? ''}
srcSet={Object.keys(server.item?.thumbnail.versions ?? {})
.map(
(key) =>
`${server.item?.thumbnail.versions?.[key]} ${key.replace('@', '')}`,
)
.join(', ')}
className={classes.header}
/>
<div className={classes.topSection}>
<h1>{server.item?.domain}</h1>
<p>{server.item?.description}</p>
</div>
<TabList>
<TabLink to={path} exact>
<FormattedMessage
id='custom_homepage.latest_activity'
defaultMessage='Latest activity'
/>
</TabLink>
<TabLink to={`${path}/about`} exact>
<FormattedMessage id='custom_homepage.about' defaultMessage='About' />
</TabLink>
</TabList>
<Switch>
<Route path={path} exact component={LatestActivity} />
<Route path={`${path}/about`} exact component={About} />
</Switch>
<Helmet>
<title>{server.item?.domain}</title>
<meta name='robots' content='all' />
</Helmet>
</div>
);
};

View File

@ -0,0 +1,35 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
import { Callout } from 'flavours/glitch/components/callout';
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
import { useAppDispatch } from 'flavours/glitch/store';
import classes from './styles.module.scss';
export const LatestActivity = () => {
const dispatch = useAppDispatch();
useEffect(() => {
void dispatch(expandCommunityTimeline());
}, [dispatch]);
return (
<StatusListContainer
prepend={
<Callout className={classes.banner}>
<FormattedMessage
id='custom_homepage.these_are_the_latest_posts'
defaultMessage='These are the latest 40 posts from accounts on this server.'
/>
</Callout>
}
scrollKey='custom_homepage'
timelineId='community'
maxItems={40}
bindToDocument
/>
);
};

View File

@ -0,0 +1,145 @@
.page {
border-radius: 16px;
border: 1px solid var(--color-border-primary);
background: var(--color-bg-primary);
min-height: 100%;
:global(.item-list) article:last-child :global(.status) {
border-bottom: 0;
}
@media screen and (width <= 1175px) {
border-radius: 0;
}
}
.header {
aspect-ratio: 40/21;
border-radius: 16px 16px 0 0;
@media screen and (width <= 1175px) {
border-radius: 0;
}
}
.banner {
margin: 16px;
margin-bottom: 0;
}
.topSection {
display: flex;
padding: 16px;
flex-direction: column;
gap: 8px;
color: var(--color-text-primary);
h1 {
font-size: 24px;
font-weight: 500;
line-height: 30px;
letter-spacing: -0.12px;
}
p {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 16px;
line-height: 24px;
text-overflow: ellipsis;
}
}
.block {
padding: 16px;
h2 {
font-size: 16px;
font-weight: 500;
line-height: 22.4px;
margin-bottom: 8px;
}
:global(.account) {
border: 1px solid var(--color-border-primary);
padding: 18px 16px;
border-radius: 12px;
--avatar-border-radius: 50%;
}
}
.placeholder {
padding: 4px 0;
display: flex;
flex-direction: column;
gap: 12px;
:global(.skeleton) {
height: 40px;
border-radius: 12px;
background: var(--color-bg-overlay-highlight);
}
}
.minimalHeader {
padding-top: 24px;
padding-bottom: 12px;
display: flex;
align-items: center;
@media screen and (width <= 1175px) {
padding-inline-start: 12px;
padding-inline-end: 12px;
}
}
.leftSide {
font-size: 15px;
font-weight: 600;
max-width: 300px;
a {
text-decoration: none;
color: inherit;
}
}
.rightSide {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
flex: 1 0 0;
}
.minimalFooter {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--color-text-secondary);
font-size: 16px;
font-weight: 500;
line-height: 22.4px;
padding-top: 28px;
padding-bottom: 54px;
gap: 8px;
flex-wrap: wrap;
a {
color: inherit;
text-decoration: underline;
}
@media screen and (width <= 1175px) {
justify-content: center;
padding-inline-start: 12px;
padding-inline-end: 12px;
}
}
.contact {
display: flex;
gap: 8px;
}

View File

@ -12,6 +12,8 @@ import classNames from 'classnames';
import type { List, Record } from 'immutable';
import { useAppSelector } from '@/flavours/glitch/store';
import { Footer } from 'flavours/glitch/features/custom_homepage/components/footer';
import { Header } from 'flavours/glitch/features/custom_homepage/components/header';
import { CollapsibleNavigationPanel } from 'flavours/glitch/features/navigation_panel';
import { useBreakpoint } from '../hooks/useBreakpoint';
@ -85,9 +87,10 @@ export const ColumnsArea = forwardRef<
HTMLDivElement,
{
singleColumn?: boolean;
minimalShell?: boolean;
children: React.ReactElement | React.ReactElement[];
}
>(({ children, singleColumn }, ref) => {
>(({ children, minimalShell, singleColumn }, ref) => {
const renderComposePanel = !useBreakpoint('full');
const columns = useAppSelector((state) =>
(state.settings as Record<{ columns: List<Record<Column>> }>).get(
@ -98,6 +101,24 @@ export const ColumnsArea = forwardRef<
(state) => !state.modal.get('stack').isEmpty(),
);
if (minimalShell) {
return (
<div className='columns-area__panels'>
<div className='columns-area__panels__main'>
<Header />
<div className='tabs-bar__wrapper'>
<TabsBarPortal />
</div>
<div className='columns-area columns-area--mobile'>{children}</div>
<Footer />
</div>
</div>
);
}
if (singleColumn) {
return (
<div className='columns-area__panels'>
@ -112,6 +133,7 @@ export const ColumnsArea = forwardRef<
<div className='tabs-bar__wrapper'>
<TabsBarPortal />
</div>
<div className='columns-area columns-area--mobile'>{children}</div>
</main>

View File

@ -24,7 +24,15 @@ const getRegex = createSelector([
const makeGetStatusIds = (pending = false) => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()),
(state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()),
(state, { type, maxItems }) => {
const items = state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList());
if (maxItems) {
return items.take(maxItems);
}
return items;
},
(state) => state.get('statuses'),
getRegex,
], (columnSettings, statusIds, statuses, regex) => {
@ -64,8 +72,17 @@ const makeMapStateToProps = () => {
const getStatusIds = makeGetStatusIds();
const getPendingStatusIds = makeGetStatusIds(true);
const mapStateToProps = (state, { timelineId, regex, initialLoadingState = true }) => ({
statusIds: getStatusIds(state, { type: timelineId, regex }),
/**
* @param {import('flavours/glitch/store').RootState} state
* @param {Object} props
* @param {string} props.timelineId
* @param {boolean} [props.initialLoadingState]
* @param {number} [props.maxItems]
* @param {RegExp} [props.regex]
*/
const mapStateToProps = (state, { timelineId, regex, initialLoadingState = true, maxItems }) => ({
statusIds: getStatusIds(state, { type: timelineId, regex, maxItems }),
lastId: state.getIn(['timelines', timelineId, 'items'])?.last(),
isLoading: state.getIn(['timelines', timelineId, 'isLoading'], initialLoadingState),
isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),

View File

@ -32,7 +32,7 @@ import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../act
import { clearHeight } from '../../actions/height_cache';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines';
import { initialState, me, owner, singleUserMode, trendsEnabled, landingPage, localLiveFeedAccess, disableHoverCards } from '../../initial_state';
import { initialState, me, owner, singleUserMode, trendsEnabled, landingPage, localLiveFeedAccess, disableHoverCards, domain } from '../../initial_state';
import BundleColumnError from './components/bundle_column_error';
import { NavigationBar } from './components/navigation_bar';
@ -91,6 +91,7 @@ import {
import { ColumnsContextProvider } from './util/columns_context';
import { focusColumn, getFocusedItemIndex, focusItemSibling, focusFirstItem } from './util/focusUtils';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import { CustomHomepage } from 'flavours/glitch/features/custom_homepage';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
@ -185,13 +186,15 @@ class SwitchingColumnsArea extends PureComponent {
rootRedirect = '/explore';
} else if (localLiveFeedAccess === 'public' && landingPage === 'local_feed') {
rootRedirect = '/public/local';
} else if (landingPage === 'overview') {
rootRedirect = '/overview';
} else {
rootRedirect = '/about';
}
return (
<ColumnsContextProvider multiColumn={!singleColumn}>
<ColumnsArea ref={this.setRef} singleColumn={singleColumn}>
<ColumnsArea ref={this.setRef} singleColumn={singleColumn} domain={domain} minimalShell={!signedIn && landingPage === 'overview'}>
<WrappedSwitch>
<Redirect from='/' to={{pathname: rootRedirect, state: this.props.location.state}} exact />
@ -270,6 +273,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
<Route path='/overview' component={CustomHomepage} />
<Route component={BundleColumnError} />
</WrappedSwitch>
</ColumnsArea>
@ -686,13 +691,18 @@ class UI extends PureComponent {
cheat: this.handleDonate,
};
const minimalShell = !this.props.identity.signedIn && landingPage === 'overview';
return (
<Hotkeys global handlers={handlers}>
<div className={className} ref={this.setRef}>
<SkipLinks
multiColumn={layout === 'multi-column'}
onFocusGettingStartedColumn={this.handleHotkeyGoToStart}
/>
{!minimalShell && (
<SkipLinks
multiColumn={layout === 'multi-column'}
onFocusGettingStartedColumn={this.handleHotkeyGoToStart}
/>
)}
{moved && (<div className='flash-message alert'>
<FormattedMessage
id='moved_to_warning'
@ -715,7 +725,7 @@ class UI extends PureComponent {
{children}
</SwitchingColumnsArea>
<NavigationBar />
{!minimalShell && <NavigationBar />}
{layout !== 'mobile' && <PictureInPicture />}
<AlertsController />
{!disableHoverCards && <HoverCardController />}