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' && (
+
+ )}
+
+ {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 (
-
-
-
- {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 (
+
+
+
+ {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: