Alt Text for Photos

* Add an Alt Text field for users to describe their photos for accessibility.
* Alt texts appear on mouse over on Gallery pages, in the lightbox modal (on
  mouse over or by clicking the ALT button that appears), and in a box on the
  permalink page below the photo caption.
* Max length of Alt Text is 5,000 characters.
* Fix a bug with the right-click blocker not working on the lightbox modal.
main
Noah Petherbridge 2024-03-15 22:02:24 -07:00
parent 742a5fa1af
commit cf6249c415
10 changed files with 161 additions and 25 deletions

View File

@ -94,6 +94,7 @@ var (
const (
MaxPhotoWidth = 1280
ProfilePhotoWidth = 512
AltTextMaxLength = 5000
// Quotas for uploaded photos.
PhotoQuotaUncertified = 6

View File

@ -5,6 +5,7 @@ import (
"net/http"
"path/filepath"
"strconv"
"strings"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config"
@ -71,7 +72,8 @@ func Edit() http.HandlerFunc {
// Are we saving the changes?
if r.Method == http.MethodPost {
var (
caption = r.FormValue("caption")
caption = strings.TrimSpace(r.FormValue("caption"))
altText = strings.TrimSpace(r.FormValue("alt_text"))
isExplicit = r.FormValue("explicit") == "true"
isGallery = r.FormValue("gallery") == "true"
visibility = models.PhotoVisibility(r.FormValue("visibility"))
@ -85,6 +87,10 @@ func Edit() http.HandlerFunc {
goingCircle = visibility == models.PhotoInnerCircle && visibility != photo.Visibility
)
if len(altText) > config.AltTextMaxLength {
altText = altText[:config.AltTextMaxLength]
}
// Respect the Site Gallery throttle in case the user is messing around.
if SiteGalleryThrottled {
isGallery = false
@ -99,6 +105,7 @@ func Edit() http.HandlerFunc {
}
photo.Caption = caption
photo.AltText = altText
photo.Explicit = isExplicit
photo.Gallery = isGallery
photo.Visibility = visibility

View File

@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
@ -60,7 +61,8 @@ func Upload() http.HandlerFunc {
// Are they POSTing?
if r.Method == http.MethodPost {
var (
caption = r.PostFormValue("caption")
caption = strings.TrimSpace(r.PostFormValue("caption"))
altText = strings.TrimSpace(r.PostFormValue("alt_text"))
isExplicit = r.PostFormValue("explicit") == "true"
visibility = r.PostFormValue("visibility")
isGallery = r.PostFormValue("gallery") == "true"
@ -74,6 +76,10 @@ func Upload() http.HandlerFunc {
isGallery = false
}
if len(altText) > config.AltTextMaxLength {
altText = altText[:config.AltTextMaxLength]
}
// Are they at quota already?
if photoCount >= photoQuota {
session.FlashError(w, r, "You have too many photos to upload a new one. Please delete a photo to make room for a new one.")
@ -135,6 +141,7 @@ func Upload() http.HandlerFunc {
Filename: filename,
CroppedFilename: cropFilename,
Caption: caption,
AltText: altText,
Visibility: models.PhotoVisibility(visibility),
Gallery: isGallery,
Explicit: isExplicit,

View File

@ -19,6 +19,7 @@ type Photo struct {
CroppedFilename string // if cropped, e.g. for profile photo
Filesize int64
Caption string
AltText string
Flagged bool // photo has been reported by the community
Visibility PhotoVisibility `gorm:"index"`
Gallery bool `gorm:"index"` // photo appears in the public gallery (if public)

View File

@ -12,6 +12,10 @@ abbr {
cursor: pointer;
}
.cursor-default {
cursor: default;
}
img {
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
user-drag: none;
@ -46,10 +50,11 @@ img {
/* Photo modals in addition to Bulma .modal-content */
.photo-modal {
width: calc(100vw - 40px);
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
}
.photo-modal #detailImg {
position: relative;
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
@ -57,6 +62,14 @@ img {
.photo-modal img {
max-height: calc(100vh - 50px);
}
.photo-modal .alt-text {
position: absolute;
bottom: 4px;
left: 4px;
}
.line-breakable {
white-space: pre-line;
}
/* Custom bulma tag colors */
.tag:not(body).is-private.is-light {

View File

@ -5,7 +5,7 @@ document.addEventListener('DOMContentLoaded', () => {
cls = 'is-active';
// Disable context menu on all images.
(document.querySelectorAll('img, video') || []).forEach(node => {
(document.querySelectorAll('img, video, #detailImg') || []).forEach(node => {
node.addEventListener('contextmenu', (e) => {
$modal.classList.add(cls);
e.preventDefault();

View File

@ -39,7 +39,7 @@
<li><a href="#private-avatar">Can my <strong>Profile Picture be kept private?</strong></a></li>
<li><a href="#profile-visibility">What are the <strong>visibility options</strong> for my profile page?</a></li>
<li><a href="#delete-messages">How do I delete direct messages (DMs)?</a></li>
<li><a href="#blocking">How does <strong>blocking somebody</strong> work on nonshy?</a> <span class="tag is-success">NEW Jan 5 2024</span></li>
<li><a href="#blocking">How does <strong>blocking somebody</strong> work on nonshy?</a></li>
</ul>
</li>
<li>
@ -48,11 +48,12 @@
<li><a href="#nudes-required">Do I have to post my nudes here?</a></li>
<li><a href="#face-in-nudes">Do I have to include my face in my nudes?</a></li>
<li><a href="#site-gallery">What appears on the Site Gallery?</a></li>
<li><a href="#site-gallery-throttle">Why can't I feature my photo on the Site Gallery?</a> <span class="tag is-success">NEW Jan 5 2024</span></li>
<li><a href="#site-gallery-throttle">Why can't I feature my photo on the Site Gallery?</a></li>
<li><a href="#other-people">Can I include other people in my photos?</a></li>
<li><a href="#define-explicit">What is considered "explicit" in photos?</a></li>
<li><a href="#photoshop">Are digitally altered or 'photoshopped' pictures okay?</a></li>
<li><a href="#downloading">Does this site <strong>prevent people from downloading</strong> my pictures?</a> <span class="tag is-success">UPDATED Jan 10 2024</span></li>
<li><a href="#downloading">Does this site <strong>prevent people from downloading</strong> my pictures?</a></li>
<li><a href="#alt-text">What is <strong>alt text</strong> on photos about?</a> <span class="tag is-success">NEW Mar 10 2024</span></li>
</ul>
</li>
<li>
@ -705,10 +706,6 @@
<h3 id="downloading">Does this site prevent people from downloading my pictures?</h3>
<p>
<span class="tag is-success">Updated Jan 10 2024</span>
</p>
<p>
As of November 2023, the {{PrettyTitle}} website does discourage the downloading of pictures
to the limited extent that a web page is able to. We have a right-click handler (long press
@ -763,6 +760,32 @@
<a href="/contact?subject=report.user">report them</a> and let us know!
</p>
<h3 id="alt-text">What is alt text on photos about?</h3>
<p>
<span class="tag is-success">NEW: March 15 2024</span>
</p>
<p>
When uploading a photo to your gallery, you can write an "alt text" description of the photo
to help with accessibility for the visually impaired. The alt text will appear when hovering
a mouse cursor over an image, in the lightbox modal on the Gallery page (where a photo appears
in full size over a dimmed background), and beneath the photo on its permalink or comments page.
</p>
<p>
It is highly recommended to describe your pictures with alt text. Not only does it help
{{PrettyTitle}} to be more inclusive to members with disabilities, but it can also just be
a lot of fun to write text descriptions of your nude and sexy photos!
</p>
<p>
If your photo includes any text that is relevant to the meaning of the photo (such as a selfie
of you standing in front of a nude beach sign), the alt text is a good place to transcribe the
text so that it is accessible to members with disabilities and it can be read aloud by their
screen reader software or similar.
</p>
<h1 id="forum-faqs">Forum FAQs</h1>
<h3 id="forum-badges">What do the various badges on the forum mean?</h3>

View File

@ -175,6 +175,11 @@
would be visible. -->
<div id="detailImg">
<img style="visibility: hidden">
<!-- Alt Text button for accessibility -->
<button class="button is-small alt-text py-1 px-2">
<strong>ALT</strong>
</button>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
@ -512,13 +517,19 @@
<!-- GIF video? -->
{{if HasSuffix .Filename ".mp4"}}
<video loop controls controlsList="nodownload"
{{if .AltText}}title="{{.AltText}}"{{end}}
{{if BlurExplicit .}}class="blurred-explicit"
{{else if (not (eq ($Root.CurrentUser.GetProfileField "autoplay_gif") "false"))}}autoplay
{{end}}>
<source src="{{PhotoURL .Filename}}" type="video/mp4">
</video>
{{else}}
<img src="{{PhotoURL .Filename}}" loading="lazy"{{if BlurExplicit .}} class="blurred-explicit"{{end}}>
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" target="_blank"
class="js-modal-trigger" data-target="detail-modal">
<img src="{{PhotoURL .Filename}}" loading="lazy"
{{if BlurExplicit .}}class="blurred-explicit"{{end}}
{{if .AltText}}alt="{{.AltText}}" title="{{.AltText}}"{{end}}>
</a>
{{end}}
</div>
@ -633,6 +644,7 @@
<!-- GIF video? -->
{{if HasSuffix .Filename ".mp4"}}
<video loop controls controlsList="nodownload"
{{if .AltText}}title="{{.AltText}}"{{end}}
{{if BlurExplicit .}}class="blurred-explicit"
{{else if (not (eq ($Root.CurrentUser.GetProfileField "autoplay_gif") "false"))}}autoplay
{{end}}>
@ -640,9 +652,10 @@
</video>
{{else}}
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" target="_blank"
class="js-modal-trigger" data-target="detail-modal"
onclick="setModalImage(this.href)">
<img src="{{PhotoURL .Filename}}" loading="lazy"{{if BlurExplicit .}} class="blurred-explicit"{{end}}>
class="js-modal-trigger" data-target="detail-modal">
<img src="{{PhotoURL .Filename}}" loading="lazy"
{{if BlurExplicit .}}class="blurred-explicit"{{end}}
{{if .AltText}}alt="{{.AltText}}" title="{{.AltText}}"{{end}}>
</a>
{{end}}
</div>
@ -749,21 +762,36 @@
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
// Get our modal to trigger it on click of a detail img.
let $modal = document.querySelector("#detail-modal");
let $modal = document.querySelector("#detail-modal"),
$altText = $modal.getElementsByTagName("button")[0];
function setModalImage(url, altText) {
let $modalImg = document.querySelector("#detailImg"),
$img = $modalImg.getElementsByTagName("img")[0];
$img.src = url;
$modalImg.style.backgroundImage = `url(${url})`;
// Alt text?
$modalImg.title = altText;
$altText.style.display = altText ? "block" : "none";
$altText.onclick = (e) => {
window.alert(altText);
e.preventDefault();
e.stopPropagation();
return false;
}
return false;
}
document.querySelectorAll(".js-modal-trigger").forEach(node => {
let $img = node.getElementsByTagName("img"),
altText = $img[0] != undefined ? $img[0].alt : '';
node.addEventListener("click", (e) => {
e.preventDefault();
setModalImage(node.dataset.url);
setModalImage(node.dataset.url, altText);
$modal.classList.add("is-active");
})
});
});
function setModalImage(url) {
let $modalImg = document.querySelector("#detailImg"),
$img = $modalImg.getElementsByTagName("img")[0];
$img.src = url;
$modalImg.style.backgroundImage = `url(${url})`;
return false;
}
</script>
{{end}}

View File

@ -87,6 +87,14 @@
{{.Photo.Caption}}
{{else}}<em>No caption</em>{{end}}
<!-- Alt Text -->
{{if .Photo.AltText}}
<div class="box my-4 py-3 px-4 is-size-7">
<strong class="tag is-grey mr-2 cursor-default">Alt Text</strong>
<span class="line-breakable">{{.Photo.AltText}}</span>
</div>
{{end}}
<!-- Admin actions: quick mark photo as explicit -->
{{if and (.CurrentUser.IsAdmin) (not .Photo.Explicit)}}
<div class="mt-2">

View File

@ -223,10 +223,35 @@
<input type="text" class="input"
name="caption"
id="caption"
placeholder="Caption"
placeholder="A short sentence about this picture"
value="{{.EditPhoto.Caption}}">
</div>
<div class="field mb-5">
<div class="columns is-mobile mb-0">
<div class="column">
<label class="label" for="alt_text">Alt Text</label>
</div>
<div class="column is-narrow">
<small id="alt_text_length">0/5000</small>
</div>
</div>
<textarea class="textarea"
name="alt_text"
id="alt_text"
cols="40"
rows="4"
placeholder="Write a description of this photo for the visually impaired"
>{{.EditPhoto.AltText}}</textarea>
<p class="help">
Write a description of this photo to help people with disabilities (such as the
vision impaired) to understand the content and meaning of this photo.
Max 5,000 characters.
<a href="/faq#alt-text" target="_blank">Learn more <i class="fa fa-external-link"></i></a>
</p>
</div>
<div class="field mb-5">
<label class="label">Photo Visibility</label>
<div>
@ -568,6 +593,29 @@
});
{{end}}
// Alt Text helper.
document.addEventListener("DOMContentLoaded", () => {
let $altText = document.querySelector("#alt_text"),
$length = document.querySelector("#alt_text_length"),
maxLength = 5000;
$altText.maxLength = maxLength;
$altText.addEventListener("keydown", (e) => {
if ($altText.value.length >= maxLength) {
return false;
}
});
let update = () => {
$length.innerHTML = `${$altText.value.length}/${maxLength}`;
};
$altText.addEventListener("keyup", update);
$altText.addEventListener("change", update);
update();
});
// EditPhoto only: a button to crop their photo to set as a profile pic.
function setProfilePhoto() {
let $begin = document.querySelector("#editphoto-begin-crop"),