From a6f55dfc94f6b817cfd53548ae93fe70019e266b Mon Sep 17 00:00:00 2001 From: Santtu Pajukanta Date: Tue, 10 Sep 2024 21:36:58 +0300 Subject: [PATCH] feat: contact form --- backend/edegal/models/album.py | 5 +- backend/edegal/models/photographer.py | 5 + backend/edegal/templates/contact_email.txt | 15 + backend/edegal/urls.py | 34 +- backend/edegal/views.py | 100 +++++- backend/requirements.in | 3 +- backend/requirements.txt | 36 +- frontend/src/components/AlbumView/index.tsx | 181 +++++----- .../src/components/BreadcrumbBar/index.tsx | 31 +- frontend/src/components/ContactDialog.tsx | 146 +++++++++ frontend/src/components/DownloadDialog.tsx | 98 +++--- frontend/src/components/MainView.tsx | 2 +- frontend/src/components/PictureView/index.tsx | 309 ++++++++++-------- frontend/src/index.scss | 7 + frontend/src/models/Photographer.ts | 2 +- .../src/{helpers => services}/AlbumCache.ts | 0 frontend/src/services/contactPhotographer.ts | 21 ++ .../src/{helpers => services}/getAlbum.ts | 2 +- frontend/src/translations/en.ts | 35 ++ frontend/src/translations/fi.ts | 35 ++ 20 files changed, 758 insertions(+), 309 deletions(-) create mode 100644 backend/edegal/templates/contact_email.txt create mode 100644 frontend/src/components/ContactDialog.tsx rename frontend/src/{helpers => services}/AlbumCache.ts (100%) create mode 100644 frontend/src/services/contactPhotographer.ts rename frontend/src/{helpers => services}/getAlbum.ts (97%) diff --git a/backend/edegal/models/album.py b/backend/edegal/models/album.py index 88b42ac..0316381 100644 --- a/backend/edegal/models/album.py +++ b/backend/edegal/models/album.py @@ -2,14 +2,13 @@ import os import re from datetime import date -from typing import Any +from typing import Any, Self from zipfile import ZipFile from django.conf import settings from django.db import models from django.db.models import F from django.shortcuts import get_object_or_404 -from django_prose_editor.fields import ProseEditorField from mptt.models import MPTTModel, TreeForeignKey from ..utils import pick_attrs, slugify, strip_photographer_name_from_title @@ -505,7 +504,7 @@ def _update_family(self, path_changed): album.save(traverse=False) @classmethod - def get_album_by_path(cls, path, or_404=False, **extra_criteria): + def get_album_by_path(cls, path, or_404=False, **extra_criteria) -> Series | Self: # Is it a Series? try: return Series.objects.get(path=path) diff --git a/backend/edegal/models/photographer.py b/backend/edegal/models/photographer.py index 5a8447f..3608246 100644 --- a/backend/edegal/models/photographer.py +++ b/backend/edegal/models/photographer.py @@ -82,6 +82,7 @@ def make_credit(self, include_larppikuvat_profile=False, **extra_attrs): "instagram_handle", "facebook_handle", "flickr_handle", + "has_email", **extra_attrs, ) @@ -159,5 +160,9 @@ def title(self): def is_public(self): return self.cover_picture_id is not None + @property + def has_email(self): + return bool(self.email) + class Meta: ordering = ("display_name",) diff --git a/backend/edegal/templates/contact_email.txt b/backend/edegal/templates/contact_email.txt new file mode 100644 index 0000000..a2e2f5d --- /dev/null +++ b/backend/edegal/templates/contact_email.txt @@ -0,0 +1,15 @@ +Someone has reached out to you through the contact form on {{ site_name }}. +If you reply to this email, your response will be sent to the sender. + +Context (album or picture): +{{ context }} + +Sender email: +{{ email }} + +Subject: +{{ subject }} + +Message: + +{{ message }} diff --git a/backend/edegal/urls.py b/backend/edegal/urls.py index de68128..3c93a29 100644 --- a/backend/edegal/urls.py +++ b/backend/edegal/urls.py @@ -1,11 +1,31 @@ - -from .views import api_v3_view, status_view, photographers_api_v3_view, photographer_api_v3_view, random_picture_api_v3_view from django.urls import re_path +from .views import ( + api_v3_view, + contact_view, + photographer_api_v3_view, + photographers_api_v3_view, + random_picture_api_v3_view, + status_view, +) + urlpatterns = [ - re_path(r'^api/v3/status/?$', status_view, name='status_view'), - re_path(r'^api/v3/photographers/?$', photographers_api_v3_view, name='photographers_api_v3_view'), - re_path(r'^api/v3/photographers/(?P[a-z0-9-]+?)/?$', photographer_api_v3_view, name='photographer_api_v3_view'), - re_path(r'^api/v3/random/?$', random_picture_api_v3_view, name='random_picture_api_v3_view'), - re_path(r'^api/v3(?P[a-z0-9/-]+?)/?$', api_v3_view, name='api_v3_view'), + re_path(r"^api/v3/status/?$", status_view, name="status_view"), + re_path(r"^api/v3/contact/?$", contact_view, name="contact_view"), + re_path( + r"^api/v3/photographers/?$", + photographers_api_v3_view, + name="photographers_api_v3_view", + ), + re_path( + r"^api/v3/photographers/(?P[a-z0-9-]+?)/?$", + photographer_api_v3_view, + name="photographer_api_v3_view", + ), + re_path( + r"^api/v3/random/?$", + random_picture_api_v3_view, + name="random_picture_api_v3_view", + ), + re_path(r"^api/v3(?P[a-z0-9/-]+?)/?$", api_v3_view, name="api_v3_view"), ] diff --git a/backend/edegal/views.py b/backend/edegal/views.py index 4c34483..7ec5c56 100644 --- a/backend/edegal/views.py +++ b/backend/edegal/views.py @@ -1,11 +1,19 @@ -from django.http import JsonResponse -from django.shortcuts import get_object_or_404 +import logging +from typing import ClassVar, Literal + +import pydantic +from django.conf import settings +from django.core.mail import EmailMessage +from django.http import HttpRequest, JsonResponse +from django.template.loader import render_to_string +from django.views.decorators.csrf import csrf_exempt from django.views.generic.base import View -from .models import Album, Photographer, Picture +from .models import Album, Photographer, Picture, Series from .models.media_spec import FORMAT_CHOICES SUPPORTED_FORMATS = {format for (format, disp) in FORMAT_CHOICES} +logger = logging.getLogger(__name__) class StatusView(View): @@ -126,8 +134,94 @@ def get(self, request): return response +class ContactRequest(pydantic.BaseModel): + context: str = pydantic.Field(min_length=1) + email: pydantic.EmailStr + subject: Literal["permission", "takedown", "other"] + message: str = pydantic.Field(min_length=1) + + subject_map: ClassVar[dict[str, str]] = dict( + permission="Usage permission inquiry", + takedown="Takedown request", + other="Contact request", + ) + + def send(self, request: HttpRequest | None = None): + album = Album.get_album_by_path(self.context) + + if isinstance(album, Series): + # fmh + raise Album.DoesNotExist() + + if not album.photographer or not album.photographer.has_email: + raise Photographer.DoesNotExist() + + site_name = Album.objects.get(path="/").title + subject_display = self.subject_map.get(self.subject, self.subject_map["other"]) + context_display = f"{settings.EDEGAL_FRONTEND_URL}{self.context}" + + vars = dict( + site_name=site_name, + context=context_display, + email=self.email, + subject=subject_display, + message=self.message, + ) + + body = render_to_string("contact_email.txt", vars, request) + + if settings.DEBUG: + print(body) + + # send email to album.photographer.email + EmailMessage( + subject=f"[{site_name}] {subject_display} ({self.context})", + body=body, + reply_to=[self.email], + to=[album.photographer.email], + ).send() + + +class ContactView(View): + http_method_names = ["post"] + + def post(self, request): + try: + contact_request = ContactRequest.model_validate_json(request.body) + except pydantic.ValidationError as e: + logger.error("Invalid contact request (failed to validate)", exc_info=e) + return JsonResponse( + {"status": 400, "message": "Invalid request"}, + status=400, + ) + + try: + contact_request.send(request) + except Album.DoesNotExist as e: + logger.error( + "Invalid contact request (context refers to a nonexistent picture or album)", + exc_info=e, + ) + return JsonResponse( + {"status": 400, "message": "Invalid request"}, + status=400, + ) + except Photographer.DoesNotExist as e: + logger.error( + "Invalid contact request (album has no photographer or photographer has no email)", + exc_info=e, + ) + return JsonResponse( + {"status": 400, "message": "Invalid request"}, + status=400, + ) + + return JsonResponse({"status": 200, "message": "OK"}) + + api_v3_view = ApiV3View.as_view() photographers_api_v3_view = PhotographersApiV3View.as_view() photographer_api_v3_view = PhotographerApiV3View.as_view() random_picture_api_v3_view = RandomPictureAPIV3View.as_view() status_view = StatusView.as_view() +contact_view = csrf_exempt(ContactView.as_view()) diff --git a/backend/requirements.in b/backend/requirements.in index 23cca7c..59ec7b3 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -1,3 +1,4 @@ +git+https://github.com/con2/django-admin-multiupload.git@django3#egg=admin-multiupload beautifulsoup4 celery Django @@ -5,12 +6,12 @@ django-environ django-mptt django-prose-editor[sanitize] django-redis -git+https://github.com/con2/django-admin-multiupload.git@django3#egg=admin-multiupload gunicorn Pillow pillow-avif-plugin pip-tools psycopg2 +pydantic[email] python-memcached redis requests diff --git a/backend/requirements.txt b/backend/requirements.txt index 2805991..451cb39 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,17 +4,19 @@ admin-multiupload @ git+https://github.com/con2/django-admin-multiupload.git@b70 # via -r requirements.in amqp==5.2.0 # via kombu +annotated-types==0.7.0 + # via pydantic asgiref==3.8.1 # via django beautifulsoup4==4.12.3 # via -r requirements.in billiard==4.2.0 # via celery -build==1.2.1 +build==1.2.2 # via pip-tools celery==5.4.0 # via -r requirements.in -certifi==2024.7.4 +certifi==2024.8.30 # via requests charset-normalizer==3.3.2 # via requests @@ -31,7 +33,7 @@ click-plugins==1.1.1 # via celery click-repl==0.3.0 # via celery -django==5.1 +django==5.1.1 # via # -r requirements.in # django-js-asset @@ -40,17 +42,25 @@ django==5.1 django-environ==0.11.2 # via -r requirements.in django-js-asset==2.2.0 - # via django-mptt + # via + # django-mptt + # django-prose-editor django-mptt==0.16.0 # via -r requirements.in -django-prose-editor==0.7.1 +django-prose-editor==0.8.0 # via -r requirements.in django-redis==5.4.0 # via -r requirements.in +dnspython==2.6.1 + # via email-validator +email-validator==2.2.0 + # via pydantic gunicorn==23.0.0 # via -r requirements.in -idna==3.7 - # via requests +idna==3.8 + # via + # email-validator + # requests kombu==5.4.0 # via celery nh3==0.2.18 @@ -73,6 +83,10 @@ prompt-toolkit==3.0.47 # via click-repl psycopg2==2.9.9 # via -r requirements.in +pydantic==2.9.1 + # via -r requirements.in +pydantic-core==2.23.3 + # via pydantic pyproject-hooks==1.1.0 # via # build @@ -91,14 +105,18 @@ requests==2.32.3 # requests-oauthlib requests-oauthlib==2.0.0 # via -r requirements.in -setuptools==72.1.0 +setuptools==74.1.2 # via pip-tools six==1.16.0 # via python-dateutil -soupsieve==2.5 +soupsieve==2.6 # via beautifulsoup4 sqlparse==0.5.1 # via django +typing-extensions==4.12.2 + # via + # pydantic + # pydantic-core tzdata==2024.1 # via celery urllib3==2.2.2 diff --git a/frontend/src/components/AlbumView/index.tsx b/frontend/src/components/AlbumView/index.tsx index c553814..37f457e 100644 --- a/frontend/src/components/AlbumView/index.tsx +++ b/frontend/src/components/AlbumView/index.tsx @@ -50,107 +50,96 @@ function groupAlbumsByYear(subalbums: Subalbum[]): Year[] { const isPhotographerView = (album: Album) => album.path.startsWith('/photographers/'); const isTimelineView = (album: Album) => album.path.endsWith('/timeline'); -export default class AlbumView extends React.Component { - state: AlbumViewState = { - width: document.documentElement ? document.documentElement.clientWidth : 0, - }; - - render(): JSX.Element { - const { album } = this.props; - const { width } = this.state; - const thisIsPhotographerView = isPhotographerView(album); - const t = T(r => r.AlbumView); - - let body = null; - if (thisIsPhotographerView && album.credits.photographer) { - body = ( - - ); - } else if (album.body) { - body = ; - } - - const showBody = body || album.previous_in_series || album.next_in_series; - - // TODO logic is "this is not a nav-linked view", encap somewhere when it grows hairier? - const showBreadcrumb = album.breadcrumb.length && album.path !== '/photographers'; - - return ( - <> - - {showBreadcrumb ? : null} - -
- {/* Text body and previous/next links */} - {showBody ? ( -
- {album.next_in_series || album.previous_in_series ? ( -
- {album.next_in_series ? ( - « {album.next_in_series.title} - ) : null} - {album.previous_in_series ? ( - - {album.previous_in_series.title} » - - ) : null} -
- ) : null} - {body ? body : null} -
- ) : null} - - {/* Subalbums */} - {album.layout === 'yearly' ? ( -
- {groupAlbumsByYear(album.subalbums).map(({ year, subalbums }) => { - return ( -
-

{year ? year : t(r => r.unknownYear)}

- -
- ); - })} -
- ) : ( - - )} - - {/* Pictures */} - -
- - - - {isTimelineView(album) && } - - ); - } +export default function AlbumView({ album }: AlbumViewProps): JSX.Element { + const [width, setWidth] = React.useState(document.documentElement?.clientWidth ?? 0); + const thisIsPhotographerView = isPhotographerView(album); + const t = T(r => r.AlbumView); - componentDidMount(): void { - this.preloadFirstPicture(); - - window.addEventListener('resize', this.handleResize); - this.handleResize(); - } - - componentDidUpdate(): void { - this.preloadFirstPicture(); - } + const handleResize = React.useCallback(() => { + setWidth(document.documentElement!.clientWidth); + }, []); - handleResize: () => void = () => { - this.setState({ width: document.documentElement!.clientWidth }); - }; - - preloadFirstPicture(): void { - const firstPicture = this.props.album.pictures[0]; + React.useEffect(() => { + const firstPicture = album.pictures[0]; if (firstPicture) { preloadMedia(firstPicture); } + + window.addEventListener('resize', handleResize); + handleResize(); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }); + + let body = null; + if (thisIsPhotographerView && album.credits.photographer) { + body = ( + + ); + } else if (album.body) { + body = ; } + + const showBody = body || album.previous_in_series || album.next_in_series; + + // TODO logic is "this is not a nav-linked view", encap somewhere when it grows hairier? + const showBreadcrumb = album.breadcrumb.length && album.path !== '/photographers'; + + return ( + <> + + {showBreadcrumb ? : null} + +
+ {/* Text body and previous/next links */} + {showBody ? ( +
+ {album.next_in_series || album.previous_in_series ? ( +
+ {album.next_in_series ? ( + « {album.next_in_series.title} + ) : null} + {album.previous_in_series ? ( + + {album.previous_in_series.title} » + + ) : null} +
+ ) : null} + {body ? body : null} +
+ ) : null} + + {/* Subalbums */} + {album.layout === 'yearly' ? ( +
+ {groupAlbumsByYear(album.subalbums).map(({ year, subalbums }) => { + return ( +
+

{year ? year : t(r => r.unknownYear)}

+ +
+ ); + })} +
+ ) : ( + + )} + + {/* Pictures */} + +
+ + + + {isTimelineView(album) && } + + ); } diff --git a/frontend/src/components/BreadcrumbBar/index.tsx b/frontend/src/components/BreadcrumbBar/index.tsx index 94bab67..45fed6f 100644 --- a/frontend/src/components/BreadcrumbBar/index.tsx +++ b/frontend/src/components/BreadcrumbBar/index.tsx @@ -6,13 +6,14 @@ import { Link } from 'react-router-dom'; import editorIcons from 'material-design-icons/sprites/svg-sprite/svg-sprite-editor-symbol.svg'; import socialIcons from 'material-design-icons/sprites/svg-sprite/svg-sprite-social-symbol.svg'; -import { getCached } from '../../helpers/getAlbum'; +import { getCached } from '../../services/getAlbum'; import Album from '../../models/Album'; import { T } from '../../translations'; -import DownloadDialog, { useDownloadDialogState } from '../DownloadDialog'; +import DownloadDialog, { useDialogState } from '../DownloadDialog'; import './index.scss'; import { breadcrumbSeparator, getBreadcrumbTitle, getFullBreadcrumb } from '../../helpers/breadcrumb'; +import ContactDialog from '../ContactDialog'; const downloadAlbumPollingDelay = 3000; @@ -28,7 +29,16 @@ export function BreadcrumbBar({ album }: { album: Album }): JSX.Element { album.credits.photographer && album.credits.photographer.path !== album.path; const [isDownloadPreparing, setDownloadPreparing] = React.useState(false); - const { isDownloadDialogOpen, openDownloadDialog, closeDownloadDialog } = useDownloadDialogState(); + const { + isDialogOpen: isDownloadDialogOpen, + openDialog: openDownloadDialog, + closeDialog: closeDownloadDialog, + } = useDialogState(); + const { + isDialogOpen: isContactDialogOpen, + openDialog: openContactDialog, + closeDialog: closeContactDialog, + } = useDialogState(); const downloadAlbum = React.useCallback(async () => { let downloadableAlbum = album; @@ -50,6 +60,11 @@ export function BreadcrumbBar({ album }: { album: Album }): JSX.Element { window.location.href = downloadableAlbum.download_url; }, [album]); + const handleContactPhotographer = React.useCallback(() => { + closeDownloadDialog(); + openContactDialog(); + }, []); + return ( r.DownloadAlbumDialog)} /> + + ); } diff --git a/frontend/src/components/ContactDialog.tsx b/frontend/src/components/ContactDialog.tsx new file mode 100644 index 0000000..ef3d451 --- /dev/null +++ b/frontend/src/components/ContactDialog.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import Modal from 'react-bootstrap/Modal'; +import Form from 'react-bootstrap/Form'; + +import { T } from '../translations'; +import Album from '../models/Album'; +import Picture from '../models/Picture'; +import contactPhotographer, { ContactRequest } from '../services/contactPhotographer'; + +interface Props { + album: Album; + picture?: Picture; + isOpen: boolean; + onClose(): void; +} + +export function ContactDialog({ onClose, isOpen, album, picture }: Props): JSX.Element { + const [page, setPage] = React.useState<'form' | 'success'>('form'); + const [isSending, setSending] = React.useState(false); + const t = React.useMemo(() => T(r => r.ContactDialog), []); + + const handleClose = React.useCallback(() => { + setSending(false); + onClose(); + + // avoid flash of contact form during closing animation + setTimeout(() => setPage('form'), 500); + }, [onClose]); + + const handleSuccess = React.useCallback(() => { + setSending(false); + setPage('success'); + }, []); + + const handleError = React.useCallback(() => { + setSending(false); + alert(t(r => r.errorText)); + }, []); + + const handleSubmit = React.useCallback( + (event: React.SyntheticEvent) => { + // NOTE: preventDefault must happen sync! + event.preventDefault(); + + const formData = new FormData(event.target as HTMLFormElement); + const contactRequest: ContactRequest = (Object.fromEntries( + formData.entries() + ) as unknown) as ContactRequest; + setSending(true); + contactPhotographer(contactRequest).then(handleSuccess, handleError); + }, + [handleClose] + ); + + const choices = [ + { slug: 'takedown', title: t(r => r.fields.subject.choices.takedown) }, + { slug: 'permission', title: t(r => r.fields.subject.choices.permission) }, + { slug: 'other', title: t(r => r.fields.subject.choices.other) }, + ]; + + const path = picture ? picture.path : album.path; + + return ( + + {page === 'form' && ( +
+ + {t(r => r.dialogTitle)} + + + + + {t(r => r.fields.recipient.title)} + + + + + + {picture ? t(r => r.fields.picture.title) : t(r => r.fields.album.title)} + + + + + + {t(r => r.fields.email.title)} + + + + + {t(r => r.fields.subject.title)} + + + {choices.map(({ slug, title }) => ( + + ))} + + + + + {t(r => r.fields.message.title)} + + + + + + + + +
+ )} + + {page === 'success' && ( + <> + + {t(r => r.dialogTitle)} + + + +

{t(r => r.successText)}

+
+ + + + + + )} +
+ ); +} + +export default ContactDialog; diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index beaf75f..2c0bd62 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -21,6 +21,7 @@ interface DownloadDialogTranslations { preparingDownloadButtonText?: string; termsAndConditions: string; twitterCredit: string; + contactPhotographer: string; } interface DownloadDialogProps { @@ -29,21 +30,23 @@ interface DownloadDialogProps { isOpen: boolean; onClose(): void; onAccept(): void; + onContactPhotographer(): void; t: TranslationFunction; // TODO } -export function useDownloadDialogState() { - const [isDownloadDialogOpen, setDownloadDialogOpen] = React.useState(false); - const openDownloadDialog = React.useCallback(() => setDownloadDialogOpen(true), []); - const closeDownloadDialog = React.useCallback(() => setDownloadDialogOpen(false), []); +export function useDialogState() { + const [isDialogOpen, setDialogOpen] = React.useState(false); + const openDialog = React.useCallback(() => setDialogOpen(true), []); + const closeDialog = React.useCallback(() => setDialogOpen(false), []); - return { isDownloadDialogOpen, openDownloadDialog, closeDownloadDialog }; + return { isDialogOpen, openDialog, closeDialog }; } export function DownloadDialog({ album, onAccept, onClose, + onContactPhotographer, t, isPreparing, isOpen, @@ -59,6 +62,11 @@ export function DownloadDialog({ setTermsAndConditionsAccepted(false); onClose(); }, [onClose]); + const handleContactPhotographer = React.useCallback(() => { + setDownloadButtonClicked(false); + setTermsAndConditionsAccepted(false); + onContactPhotographer(); + }, [onContactPhotographer]); const toggleTermsAndConditionsAccepted = React.useCallback(() => { setTermsAndConditionsAccepted(!isTermsAndConditionsAccepted); }, [isTermsAndConditionsAccepted]); @@ -83,12 +91,6 @@ export function DownloadDialog({

r.defaultTerms)} /> - {photographer && photographer.email ? ( -

- {t(r => r.contact)} {photographer.email} -

- ) : null} - {haveTwitter ? ( <>

@@ -162,34 +164,52 @@ export function DownloadDialog({

- - - - +
+ + + +
+ +
+
+ {album.credits.photographer ? ( + + ) : ( +
+ )} +
+ +
); diff --git a/frontend/src/components/MainView.tsx b/frontend/src/components/MainView.tsx index 877f451..788bdba 100644 --- a/frontend/src/components/MainView.tsx +++ b/frontend/src/components/MainView.tsx @@ -5,7 +5,7 @@ import { RouteComponentProps, StaticContext } from 'react-router'; import AlbumView from './AlbumView'; import Loading from './Loading'; import PictureView from './PictureView'; -import { Content, getAlbum } from '../helpers/getAlbum'; +import { Content, getAlbum } from '../services/getAlbum'; import ErrorMessage from './ErrorMessage'; import { T } from '../translations'; import { getDocumentTitle } from '../helpers/breadcrumb'; diff --git a/frontend/src/components/PictureView/index.tsx b/frontend/src/components/PictureView/index.tsx index dd166c7..7a5edac 100644 --- a/frontend/src/components/PictureView/index.tsx +++ b/frontend/src/components/PictureView/index.tsx @@ -12,14 +12,15 @@ import DownloadDialog from '../DownloadDialog'; import './index.css'; import replaceFormat from '../../helpers/replaceFormat'; +import ContactDialog from '../ContactDialog'; type Direction = 'next' | 'previous' | 'album'; -const keyMap: { [keyCode: number]: Direction } = { - 27: 'album', // escape - 33: 'previous', // page up - 34: 'next', // page down - 37: 'previous', // left arrow - 39: 'next', // right arrow +const keyMap: { [keyCode: string]: Direction } = { + Escape: 'album', // + PageUp: 'previous', + PageDown: 'next', + ArrowLeft: 'previous', + ArrowRight: 'next', }; type PictureViewProps = RouteComponentProps<{ path: string }> & { @@ -28,162 +29,182 @@ type PictureViewProps = RouteComponentProps<{ path: string }> & { fromAlbumView?: boolean; }; -interface PictureViewState { - downloadDialogOpen: boolean; -} +function PictureView({ album, picture, fromAlbumView, history }: PictureViewProps): JSX.Element { + const t = T(r => r.PictureView); + const { preview, title } = picture; + const { src } = preview; + const additionalFormats = preview.additional_formats ?? []; + const [isDownloadDialogOpen, setDownloadDialogOpen] = React.useState(false); + const [isContactDialogOpen, setContactDialogOpen] = React.useState(false); + + const goTo = React.useCallback( + (direction: Direction) => () => { + // TODO hairy due to refactoring .album away from picture, ameliorate + const destination = direction === 'album' ? album : picture[direction]; + if (destination) { + if (direction === 'album') { + if (fromAlbumView) { + // arrived from album view + // act as the browser back button + history.goBack(); + } else { + // arrived using direct link + history.push(destination.path); + } + } else { + history.replace(destination.path); + } + } + }, + [album, picture, fromAlbumView, history] + ); + + const onKeyDown = React.useCallback( + (event: KeyboardEvent) => { + if (isDownloadDialogOpen || isContactDialogOpen) { + return; + } -class PictureView extends React.Component { - state: PictureViewState = { downloadDialogOpen: false }; - - render() { - const t = T(r => r.PictureView); - const { album, picture } = this.props; - const { preview, title } = picture; - const { src } = preview; - const additionalFormats = preview.additional_formats ?? []; - const { downloadDialogOpen } = this.state; - - return ( -
- - {additionalFormats.map(format => ( - - ))} - {title} - - - {picture.previous ? ( -
this.goTo('previous')} - className="PictureView-nav PictureView-nav-previous" - title={t(r => r.previousPicture)} - > - - - -
- ) : null} + if (event.altKey || event.ctrlKey || event.metaKey) { + return; + } - {picture.next ? ( -
this.goTo('next')} - className="PictureView-nav PictureView-nav-next" - title={t(r => r.nextPicture)} - > - - - -
- ) : null} + if (event.key === 'r' || event.key === 'R') { + history.push('/random'); + return; + } + + const direction = keyMap[event.code]; + if (direction) { + goTo(direction); + } + }, + [history, goTo, isDownloadDialogOpen, isContactDialogOpen] + ); + const preloadPreviousAndNext = React.useCallback((picture: Picture) => { + // use setTimeout to not block rendering of current picture – improves visible latency + setTimeout(() => { + if (picture.previous) { + preloadMedia(picture.previous); + } + + if (picture.next) { + preloadMedia(picture.next); + } + }, 0); + }, []); + + const closeDownloadDialog = React.useCallback(() => { + setTimeout(() => { + setDownloadDialogOpen(false); + }, 0); + }, []); + + const openDownloadDialog = React.useCallback(() => { + setDownloadDialogOpen(true); + }, []); + + const contactPhotographer = React.useCallback(() => { + setTimeout(() => { + setDownloadDialogOpen(false); + setContactDialogOpen(true); + }, 0); + }, []); + + const closeContactDialog = React.useCallback(() => { + setContactDialogOpen(false); + }, []); + + const downloadPicture = React.useCallback(() => { + window.open(picture.original.src); + }, [picture]); + + React.useEffect(() => { + preloadPreviousAndNext(picture); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }); + + return ( +
+ + {additionalFormats.map(format => ( + + ))} + {title} + + + {picture.previous ? (
this.goTo('album')} - className="PictureView-action PictureView-action-exit" - title={t(r => r.backToAlbum)} + onClick={goTo('previous')} + className="PictureView-nav PictureView-nav-previous" + title={t(r => r.previousPicture)} > - +
+ ) : null} - {album.is_downloadable && picture.original ? ( + {picture.next ? ( +
r.nextPicture)} + > + + + +
+ ) : null} + +
r.backToAlbum)} + > + + + +
+ + {album.is_downloadable && picture.original && ( + <>
r.downloadOriginal)} > - r.DownloadDialog)} - album={album} - onAccept={this.downloadPicture} - onClose={this.closeDownloadDialog} - isOpen={downloadDialogOpen} - />
- ) : null} -
- ); - } - - componentDidMount() { - document.addEventListener('keydown', this.onKeyDown); - - this.preloadPreviousAndNext(this.props.picture); - } - componentWillUnmount() { - document.removeEventListener('keydown', this.onKeyDown); - } - - componentDidUpdate(prevProps: PictureViewProps) { - if (this.props.picture.path !== prevProps.picture.path) { - this.preloadPreviousAndNext(this.props.picture); - } - } - - preloadPreviousAndNext(picture: Picture) { - // use setTimeout to not block rendering of current picture – improves visible latency - setTimeout(() => { - if (picture.previous) { - preloadMedia(picture.previous); - } - - if (picture.next) { - preloadMedia(picture.next); - } - }, 0); - } - - onKeyDown = (event: KeyboardEvent) => { - if (event.altKey || event.ctrlKey || event.metaKey) { - return; - } - - if (event.key === 'r' || event.key === 'R') { - this.props.history.push('/random'); - return; - } - - const direction = keyMap[event.keyCode]; - if (direction) { - this.goTo(direction); - } - }; - - goTo(direction: Direction) { - // TODO hairy due to refactoring .album away from picture, ameliorate - const { album, picture, fromAlbumView, history } = this.props; - const destination = direction === 'album' ? album : picture[direction]; - if (destination) { - if (direction === 'album') { - if (fromAlbumView) { - // arrived from album view - // act as the browser back button - history.goBack(); - } else { - // arrived using direct link - history.push(destination.path); - } - } else { - history.replace(destination.path); - } - } - } - - // XXX Whytf is setTimeout required here? - closeDownloadDialog = () => { - setTimeout(() => this.setState({ downloadDialogOpen: false }), 0); - }; - openDownloadDialog = () => { - this.setState({ downloadDialogOpen: true }); - }; - downloadPicture = () => { - window.open(this.props.picture.original.src); - }; + r.DownloadDialog)} + album={album} + onAccept={downloadPicture} + onClose={closeDownloadDialog} + onContactPhotographer={contactPhotographer} + isOpen={isDownloadDialogOpen} + /> + + )} + {album.credits.photographer && ( + + )} +
+ ); } export default withRouter(PictureView); diff --git a/frontend/src/index.scss b/frontend/src/index.scss index 767ee74..96aa604 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -21,3 +21,10 @@ body { .modal-content { overflow: auto; } + +.link-subtle { + text-decoration: none; + &:hover { + text-decoration: underline; + } +} diff --git a/frontend/src/models/Photographer.ts b/frontend/src/models/Photographer.ts index 61a9307..d2812c8 100644 --- a/frontend/src/models/Photographer.ts +++ b/frontend/src/models/Photographer.ts @@ -11,12 +11,12 @@ export interface LarppikuvatProfile { export default interface Photographer { path: string; display_name: string; - email: string; homepage_url: string; twitter_handle: string; instagram_handle: string; facebook_handle: string; flickr_handle: string; + has_email: boolean; larppikuvat_profile?: LarppikuvatProfile; } diff --git a/frontend/src/helpers/AlbumCache.ts b/frontend/src/services/AlbumCache.ts similarity index 100% rename from frontend/src/helpers/AlbumCache.ts rename to frontend/src/services/AlbumCache.ts diff --git a/frontend/src/services/contactPhotographer.ts b/frontend/src/services/contactPhotographer.ts new file mode 100644 index 0000000..78c93d4 --- /dev/null +++ b/frontend/src/services/contactPhotographer.ts @@ -0,0 +1,21 @@ +import Config from '../Config'; + +export interface ContactRequest { + context: string; + subject: 'permission' | 'takedown' | 'other'; + email: string; + message: string; +} + +export default async function contactPhotographer(contact: ContactRequest) { + const url = `${Config.backend.baseUrl}${Config.backend.apiPrefix}/contact`; + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + accept: 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify(contact), + }); +} diff --git a/frontend/src/helpers/getAlbum.ts b/frontend/src/services/getAlbum.ts similarity index 97% rename from frontend/src/helpers/getAlbum.ts rename to frontend/src/services/getAlbum.ts index 8960490..a96b579 100644 --- a/frontend/src/helpers/getAlbum.ts +++ b/frontend/src/services/getAlbum.ts @@ -1,5 +1,5 @@ import Config from '../Config'; -import AlbumCache from '../helpers/AlbumCache'; +import AlbumCache from './AlbumCache'; import Album from '../models/Album'; import Picture from '../models/Picture'; diff --git a/frontend/src/translations/en.ts b/frontend/src/translations/en.ts index e61e31e..70b5c81 100644 --- a/frontend/src/translations/en.ts +++ b/frontend/src/translations/en.ts @@ -34,6 +34,7 @@ const translations = { acceptTermsAndConditions: 'I accept these terms and conditions', defaultTerms: 'Terms and conditions missing. These pictures are covered by standard copyright protections, and unless you are certain the photographer will not object to your intended use, you should contact them and ask for permission.', + contactPhotographer: 'Contact photographer', }, DownloadDialog: { dialogTitle: 'Download original photo', @@ -51,6 +52,40 @@ const translations = { acceptTermsAndConditions: 'I accept these terms and conditions', defaultTerms: 'Terms and conditions missing. The photo is covered by standard copyright protections, and unless you are certain the photographer will not object to your intended use, you should contact them and ask for permission.', + contactPhotographer: 'Contact photographer', + }, + ContactDialog: { + closeButtonText: 'Close', + dialogTitle: 'Contact photographer', + sendingContactText: 'Sending', + sendContactText: 'Send', + errorText: 'We were unable to send your message. Please try again later.', + successText: 'Your message has been sent.', + fields: { + subject: { + title: 'Subject', + choices: { + takedown: 'I am in this photo and I want it removed', + permission: "I'd like to ask for permission to use this photo", + other: 'Other', + }, + }, + recipient: { + title: 'Recipient', + }, + email: { + title: 'Your email address', + }, + album: { + title: 'Album', + }, + picture: { + title: 'Picture', + }, + message: { + title: 'Message', + }, + }, }, ErrorBoundary: { defaultMessage: diff --git a/frontend/src/translations/fi.ts b/frontend/src/translations/fi.ts index cc32a22..9cd0f36 100644 --- a/frontend/src/translations/fi.ts +++ b/frontend/src/translations/fi.ts @@ -35,6 +35,7 @@ const translations: Translations = { acceptTermsAndConditions: 'Hyväksyn ehdot', defaultTerms: 'Albumin käyttöehdot puuttuvat. Kuva on tästä huolimatta tekijänoikeuden suojaama. Ellet ole varma, että kuvaaja hyväksyy aiotun käytön, ota yhteyttä kuvaajaan ja kysy lupaa kuvien käyttöön.', + contactPhotographer: 'Ota yhteyttä valokuvaajaan', }, DownloadDialog: { dialogTitle: 'Lataa alkuperäinen kuva', @@ -52,6 +53,40 @@ const translations: Translations = { acceptTermsAndConditions: 'Hyväksyn ehdot', defaultTerms: 'Kuvan käyttöehdot puuttuvat. Kuva on tästä huolimatta tekijänoikeuden suojaama. Ellet ole varma, että kuvaaja hyväksyy aiotun käytön, ota yhteyttä kuvaajaan ja kysy lupaa kuvan käyttöön.', + contactPhotographer: 'Ota yhteyttä valokuvaajaan', + }, + ContactDialog: { + closeButtonText: 'Sulje', + dialogTitle: 'Ota yhteyttä valokuvaajaan', + sendingContactText: 'Lähetetään', + sendContactText: 'Lähetä', + errorText: 'Viestin lähettäminen epäonnistui. Yritä myöhemmin uudelleen.', + successText: 'Viestisi on lähetetty.', + fields: { + subject: { + title: 'Aihe', + choices: { + takedown: 'Olen tässä kuvassa ja haluan, että se poistetaan', + permission: 'Haluaisin käyttää tätä kuvaa', + other: 'Muu', + }, + }, + recipient: { + title: 'Vastaanottaja', + }, + email: { + title: 'Sähköpostiosoitteesi', + }, + album: { + title: 'Albumi', + }, + picture: { + title: 'Kuva', + }, + message: { + title: 'Viesti', + }, + }, }, ErrorBoundary: { defaultMessage: