Skip to content

Commit

Permalink
Add Perma links (UriFragments)
Browse files Browse the repository at this point in the history
  • Loading branch information
sepulzera committed Dec 14, 2022
1 parent b4d9a36 commit 762ccc9
Show file tree
Hide file tree
Showing 24 changed files with 355 additions and 139 deletions.
2 changes: 2 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import InternalErrorDialog from './components/InternalErrorDialog';
import IntlMessagesCreator from './components/IntlMessagesCreator';
import { isDemoMode } from '../common/utility';
import PageSpinner from './components/PageSpinner';
import PageScroller from '../common/components/PageScroller';

const App = () => {
const main = (
Expand All @@ -42,6 +43,7 @@ const AppFC: React.FC = () => (
<Footer />
<InternalErrorDialog />
<AutoLogin />
<PageScroller />
</>
);

Expand Down
10 changes: 7 additions & 3 deletions src/app/AutoLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ interface IStateProps {
}

interface IAutoLoginState {
originUrl: string;
originUrl: string;
originSearch: string;
originHash: string;
}

type IProps = IStateProps & IDispatchProps & IAutoLoginClassProps;
Expand All @@ -52,9 +53,12 @@ class AutoLoginClass extends Component<IProps, IAutoLoginState> {
constructor(props: IProps) {
super(props);

// console.log(`[AutoLogin::ctor] loc=${JSON.stringify(props.loc)}`);

this.state = {
originUrl: props.loc.pathname,
originUrl: props.loc.pathname,
originSearch: props.loc.search,
originHash: props.loc.hash,
};
}

Expand All @@ -78,7 +82,7 @@ class AutoLoginClass extends Component<IProps, IAutoLoginState> {
// console.log(`[AutoLogin::componentDidUpdate] user is logged in, forward to home ("${path}")`);
this.props.nav(path, { replace: true });
} else if (this.props.loc.pathname !== originUrl) {
const path = `${originUrl}${this.state.originSearch}`;
const path = `${originUrl}${this.state.originSearch}${this.state.originHash}`;
// console.log(`[AutoLogin::componentDidUpdate] user is logged in, forward to origin url ("${path}")`);
this.props.nav(path, { replace: true });
}
Expand Down
3 changes: 3 additions & 0 deletions src/app/css/print.css
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,7 @@
th.visible-sm, td.visible-sm {
display: table-cell !important;
}
a.headerlink {
display: none !important;
}
}
1 change: 0 additions & 1 deletion src/browse/containers/BrowsePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ const BrowsePage: React.FC = () => {
const qsMergedString = objToSearchString(qsMergedDefaults);

useEffect(() => {
window.scrollTo(0, 0);
dispatch(SearchActions.loadRecipes(qsMergedDefaults));
}, [searchParams]);

Expand Down
17 changes: 17 additions & 0 deletions src/common/components/HeaderLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Link } from 'react-router-dom';

import '../css/header_link.css';
import PageScroller from './PageScroller';

export interface IHeaderLinkProps {
linkFor: string;
}

const HeaderLink: React.FC<IHeaderLinkProps> = ({ linkFor }: IHeaderLinkProps) => (
<>
<Link className='headerlink' to={`#${linkFor}`} title='Permalink to this headline'></Link>
<PageScroller uriFragmentId={linkFor} />
</>
);

export default HeaderLink;
41 changes: 41 additions & 0 deletions src/common/components/PageScroller.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router';
import { scrollToElement } from '../utility';

export interface IPageScrollerProps {
uriFragmentId?: string;
}

const PageScroller: React.FC<IPageScrollerProps> = ({ uriFragmentId }: IPageScrollerProps) => {
const { pathname, hash, key } = useLocation();

useEffect(() => {
if (hash === '') {
if (!uriFragmentId) {
setTimeout(() => {
// console.log('[PageScroller] No hash, scroll to top.');
window.scrollTo(0, 0);
}, 0);
}
} else {
setTimeout(() => {
const mainContainerId = 'main-container';
const mainContainerElem = document.getElementById(mainContainerId);
const mainContainerOffset = ((mainContainerElem?.getBoundingClientRect?.().top ?? 0) + window.scrollY) ?? 0;

const id = hash.replace('#', '');
if (uriFragmentId && id !== uriFragmentId) return;
const elem = document.getElementById(id);

if (elem) {
// console.log(`[PageScroller] Scroll to elem "${id}".`);
scrollToElement(elem, mainContainerOffset + 50);
}
}, 0);
}
}, [pathname, hash, key]);

return null;
};

export default PageScroller;
2 changes: 1 addition & 1 deletion src/common/components/PageWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function toCleanLocationPath(path: string): string {
}, [id, error]);

return (
<Container className={toCleanLocationPath(location.pathname)} style={{ marginTop: `${dynamicHeightContext.toolbarHeight}px` }}>
<Container id='main-container' className={toCleanLocationPath(location.pathname)} style={{ marginTop: `${dynamicHeightContext.toolbarHeight}px` }}>
<ErrorBoundary verbose printStack>
{children}
</ErrorBoundary>
Expand Down
15 changes: 15 additions & 0 deletions src/common/css/header_link.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
a.headerlink {
visibility: hidden;
padding: 0 4px;
color: inherit;
text-decoration: none;
text-decoration-color: currentColor;
}

h1:hover > a.headerlink, h2:hover > a.headerlink, h3:hover > a.headerlink, h4:hover > a.headerlink, h5:hover > a.headerlink, h6:hover > a.headerlink, dt:hover > a.headerlink, caption:hover > a.headerlink, p.caption:hover > a.headerlink, div.code-block-caption:hover > a.headerlink {
visibility: visible;
}

a.headerlink:hover {
background-color: var(--hoverBg);
}
22 changes: 22 additions & 0 deletions src/common/utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,28 @@ export function optionallyFormatMessage(intl: IntlShape, baseMessageId: string,
}
}

export function scrollToElement(element: Element, offset: number) {
function getElementOffset(elementt: Element) {
if (elementt?.getBoundingClientRect == null) {
// All current browsers and even IE11 should implement this, but you never know.
// eslint-disable-next-line no-console
console.warn('Attempted to query getBoundingClientRect, but element does not provide this function.');
return undefined;
}

return elementt.getBoundingClientRect().top + window.scrollY;
}

const elementOffset = getElementOffset(element);
if (elementOffset == null) return;
const offsetPosition = elementOffset - offset;

window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
}

export function sortByLabel(a: { label: string }, b: { label: string}): number {
return a.label.localeCompare(b.label);
}
95 changes: 46 additions & 49 deletions src/rating/components/NewRating.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,56 +73,53 @@ const NewRating: React.FC<INewRatingProps> = ({ show, recipeSlug, userId, addRat
if (!show) return null;

return (
<>
<ReduxForm
initialValues = {initialValues}
onSubmit = {handleSubmit}
subscription = {{}}
render = {({ form, handleSubmit: renderSubmit }) => (
<Form onSubmit={renderSubmit} className='new-rating'>
<ReFormStatus onSubmitSuccess={onAddRatingSuccess} />
<ReduxForm
initialValues = {initialValues}
onSubmit = {handleSubmit}
subscription = {{}}
render = {({ form, handleSubmit: renderSubmit }) => (
<Form onSubmit={renderSubmit} className='new-rating'>
<ReFormStatus onSubmitSuccess={onAddRatingSuccess} />

<InitialValuesResetter form={form} initialValues={initialValues} />
<fieldset>
<legend className='new-rating-heading'>{formatMessage(messages.new_rating_title)}</legend>
<Row>
<Col className='form-group required'>
<div className='form-label'>{formatMessage(messages.rating_label)}</div>
<Field name='rating' validate={requiredValidator} validateFields={[]}>
{fprops => (
<Ratings
stars = {fprops.input.value}
onChange = {(value: number) => { fprops.input.onChange(value); }} />
)}
</Field>
</Col>
</Row>
<Row>
<Col>
<ReInput
name = 'comment'
rows = {4}
label = {formatMessage(messages.rating_comment_label)}
placeholder = {formatMessage(messages.rating_comment_placeholder)}
required />
</Col>
</Row>
<Row>
<Col xs={12}>
<FormSpy subscription={{ values: true, submitting: true }}>
{({ values, submitting }) => (
<Button type='submit' variant='primary' disabled={!values.rating || !values.comment || submitting}>
{formatMessage(messages.submit)}
</Button>
)}
</FormSpy>
</Col>
</Row>
</fieldset>
</Form>
)} />
<hr />
</>
<InitialValuesResetter form={form} initialValues={initialValues} />
<fieldset>
<legend className='new-rating-heading'>{formatMessage(messages.new_rating_title)}</legend>
<Row>
<Col className='form-group required'>
<div className='form-label'>{formatMessage(messages.rating_label)}</div>
<Field name='rating' validate={requiredValidator} validateFields={[]}>
{fprops => (
<Ratings
stars = {fprops.input.value}
onChange = {(value: number) => { fprops.input.onChange(value); }} />
)}
</Field>
</Col>
</Row>
<Row>
<Col>
<ReInput
name = 'comment'
rows = {4}
label = {formatMessage(messages.rating_comment_label)}
placeholder = {formatMessage(messages.rating_comment_placeholder)}
required />
</Col>
</Row>
<Row>
<Col xs={12}>
<FormSpy subscription={{ values: true, submitting: true }}>
{({ values, submitting }) => (
<Button type='submit' variant='primary' disabled={!values.rating || !values.comment || submitting}>
{formatMessage(messages.submit)}
</Button>
)}
</FormSpy>
</Col>
</Row>
</fieldset>
</Form>
)} />
);
};

Expand Down
103 changes: 103 additions & 0 deletions src/rating/components/RatingComment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Button, Col, Row } from 'react-bootstrap';

import { Rating } from '../store/types';
import Icon from '../../common/components/Icon';
import P from '../../common/components/P';
import Modal from '../../common/components/Modal';
import Ratings from './Ratings';
import { Link } from 'react-router-dom';
import PageScroller from '../../common/components/PageScroller';
import { useLocation } from 'react-router';
import classNames from 'classnames';

export interface IRatingCommentProps {
rating: Rating;
onDelete?: (ratingId: number) => void;
}

interface IRatingTimestampProps {
rating: Rating;
}

const RatingTimestamp: React.FC<IRatingTimestampProps> = ({ rating }: IRatingTimestampProps) => {
const intl = useIntl();
if (rating.updateDate && new Date(rating.updateDate).getTime() > 0) {
return <Link className='rating-timestamp' to={`#rating-${rating.id}`}>{new Date(rating.updateDate).toLocaleString(intl.locale)}</Link>;
} else if (rating.pubDate && new Date(rating.pubDate).getTime() > 0) {
return <Link className='rating-timestamp' to={`#rating-${rating.id}`}>{new Date(rating.pubDate).toLocaleString(intl.locale)}</Link>;
} else {
return null;
}
};

interface IRatingCommentCommentProps {
rating: Rating;
}

const RatingCommentComment: React.FC<IRatingCommentCommentProps> = ({ rating }: IRatingCommentCommentProps) => (
<P>
{rating.comment}
</P>
);

const RatingComment: React.FC<IRatingCommentProps> = ({
rating, onDelete }: IRatingCommentProps) => {
const intl = useIntl();

const messages = defineMessages({
confirm_delete_message: {
id: 'rating_comments.confirm_delete',
description: 'Are you sure you want to delete this comment?',
defaultMessage: 'Are you sure you want to delete this comment?',
},
});

const { hash } = useLocation();
const hashId = (hash ?? '').replace('#', '');

const [showDeleteConfirm, setShowDeleteConfirm] = useState<number | undefined>();
const handleDeleteClick = (ratingId: number) => { setShowDeleteConfirm(ratingId); };
const handleDeleteAccept = () => { onDelete?.(showDeleteConfirm ?? 0); };
const handleDeleteClose = () => { setShowDeleteConfirm(undefined); };

const ratingContainerId = `rating-${rating.id}`;

return (
<div id={ratingContainerId} className={classNames('rating-comment', { 'perma-link-active': ratingContainerId === hashId })}>
<PageScroller uriFragmentId={ratingContainerId} />
<Row>
<Col xs>
<Ratings stars={rating.rating || 0} />
<div className='rating-username'>{rating.userName}</div>
</Col>
<Col xs='auto'>
<RatingTimestamp rating={rating} />
{onDelete && (
<Button variant='outline-danger' className='rating-delete-button' size='sm' onClick={() => handleDeleteClick(rating.id)}>
<Icon icon='trash' />
</Button>
)}
</Col>
</Row>
<Row>
<Col xs={12}>
<RatingCommentComment rating={rating} />
</Col>
</Row>

<Modal
show = {showDeleteConfirm != null}
title = {intl.messages['recipe.confirm_delete_title'] as string}
acceptTitle = {intl.messages['recipe.confirm_delete_accept'] as string}
onAccept = {handleDeleteAccept}
onClose = {handleDeleteClose}
acceptButtonProps = {{ variant: 'danger' }}>
{intl.formatMessage(messages.confirm_delete_message)}
</Modal>
</div>
);
};

export default RatingComment;
Loading

0 comments on commit 762ccc9

Please sign in to comment.