diff --git a/django_topics/models/seasonal_topic.py b/django_topics/models/seasonal_topic.py index ff45333529..3457ae8b62 100644 --- a/django_topics/models/seasonal_topic.py +++ b/django_topics/models/seasonal_topic.py @@ -1,7 +1,23 @@ from django.db import models from django_topics.models import Topic from django.core.exceptions import ValidationError +from django.utils.timezone import now +from datetime import datetime +class SeasonalTopicManager(models.Manager): + def get_seasonal_topic(self, lang: str, date: datetime = None) -> 'SeasonalTopic': + """ + Return seasonal topic for given date or closest date that is less than or equal to given date + @param lang: language code, "en" or "he" + @param date: datetime object + @return: + """ + date = date or now().date() + return ( + self.filter(start_date__lte=date, lang=lang) + .order_by('-start_date') + .first() + ) class SeasonalTopic(models.Model): topic = models.ForeignKey( @@ -25,6 +41,7 @@ class SeasonalTopic(models.Model): display_date_prefix = models.CharField(max_length=255, blank=True, null=True) display_date_suffix = models.CharField(max_length=255, blank=True, null=True) lang = models.CharField(max_length=2, choices=[('en', 'English'), ('he', 'Hebrew')]) + objects = SeasonalTopicManager() class Meta: unique_together = ('topic', 'start_date') @@ -61,6 +78,12 @@ def clean(self): def __str__(self): return f"{self.topic.slug} ({self.start_date})" + def get_display_start_date(self, diaspora=True): + return self.display_start_date_diaspora if diaspora else self.display_start_date_israel + + def get_display_end_date(self, diaspora=True): + return self.display_end_date_diaspora if diaspora else self.display_end_date_israel + class SeasonalTopicEnglish(SeasonalTopic): class Meta: diff --git a/reader/views.py b/reader/views.py index 0b41c10820..e5512406a1 100644 --- a/reader/views.py +++ b/reader/views.py @@ -1617,8 +1617,38 @@ def parashat_hashavua_api(request): p.update(TextFamily(Ref(p["ref"])).contents()) return jsonResponse(p, callback) +def find_holiday_in_hebcal_results(response): + for hebcal_holiday in json.loads(response.text)['items']: + if hebcal_holiday['category'] != 'holiday': + continue + for result in get_name_completions(hebcal_holiday['hebrew'], 10, False)['completion_objects']: + if result['type'] == 'Topic': + topic = Topic.init(result['key']) + if topic: + return topic.contents() + return None @catch_error_as_json +def next_holiday(request): + from datetime import datetime + from dateutil.relativedelta import relativedelta + import requests + current_date = datetime.now().date() + date_in_three_months = current_date + relativedelta(months=+3) + + # Format the date as YYYY-MM-DD + current_date = current_date.strftime('%Y-%m-%d') + date_in_three_months = date_in_three_months.strftime("%Y-%m-%d") + response = requests.get(f"https://www.hebcal.com/hebcal?v=1&cfg=json&maj=on&start={current_date}&end={date_in_three_months}") + if response.status_code == 200: + topic = find_holiday_in_hebcal_results(response) + if topic: + return jsonResponse(topic) + else: + return jsonResponse({"error": "Couldn't find any topics corresponding to HebCal results"}) + else: + return jsonResponse({"error": "Couldn't establish connection with HebCal API"}) +@catch_error_as_json def table_of_contents_api(request): return jsonResponse(library.get_toc(), callback=request.GET.get("callback", None)) @@ -2482,6 +2512,18 @@ def _internal_do_post(request, update, cat, uid, **kwargs): return jsonResponse({"error": "Unsupported HTTP method."}) +@catch_error_as_json +@csrf_exempt +def parasha_data_api(request): + from sefaria.utils.calendars import make_parashah_response_from_calendar_entry + diaspora = request.GET.get("diaspora", "1") + datetime_obj = timezone.localtime(timezone.now()) + if diaspora not in ["0", "1"]: + return jsonResponse({"error": "'Diaspora' parameter must be 1 or 0."}) + else: + diaspora = True if diaspora == "1" else False + db_parasha = get_parasha(datetime_obj, diaspora=diaspora) + return jsonResponse(make_parashah_response_from_calendar_entry(db_parasha, include_topic_slug=True)[0]) @catch_error_as_json @csrf_exempt @@ -3239,6 +3281,27 @@ def topic_ref_bulk_api(request): all_links_touched.append(ref_topic_dict) return jsonResponse(all_links_touched) +@catch_error_as_json +def seasonal_topic_api(request): + from django_topics.models import SeasonalTopic + + lang = request.GET.get("lang") + cb = request.GET.get("callback", None) + diaspora = request.GET.get("diaspora", False) + + stopic = SeasonalTopic.objects.get_seasonal_topic(lang) + if not stopic: + return jsonResponse({'error': f'No seasonal topic found for lang "{lang}"'}, status=404) + mongo_topic = Topic.init(stopic.topic.slug) + mongo_secondary_topic = Topic.init(stopic.secondary_topic.slug) if stopic.secondary_topic else None + response = {'topic': mongo_topic.contents(), + 'secondary_topic': mongo_secondary_topic.contents() if mongo_secondary_topic else None, + 'display_start_date': stopic.get_display_start_date(diaspora).isoformat() if mongo_secondary_topic else None, + 'display_end_date': stopic.get_display_end_date(diaspora).isoformat() if mongo_secondary_topic else None, + 'display_date_prefix': stopic.display_date_prefix, + 'display_date_suffix': stopic.display_date_suffix, + } + return jsonResponse(response, callback=cb) @catch_error_as_json @@ -4035,7 +4098,6 @@ def digitized_by_sefaria(request): "texts": texts, }) - def parashat_hashavua_redirect(request): """ Redirects to this week's Parashah""" diaspora = request.GET.get("diaspora", "1") @@ -4043,7 +4105,6 @@ def parashat_hashavua_redirect(request): parashah = calendars["Parashat Hashavua"] return redirect(iri_to_uri("/" + parashah["url"]), permanent=False) - def daf_yomi_redirect(request): """ Redirects to today's Daf Yomi""" calendars = get_keyed_calendar_items() diff --git a/sefaria/urls.py b/sefaria/urls.py index f07ec2338e..76968294aa 100644 --- a/sefaria/urls.py +++ b/sefaria/urls.py @@ -171,6 +171,8 @@ url(r'^api/terms/(?P.+)$', reader_views.terms_api), url(r'^api/calendars/next-read/(?P.+)$', reader_views.parasha_next_read_api), url(r'^api/calendars/?$', reader_views.calendars_api), + url(r'^api/calendars/topics/parasha/?$', reader_views.parasha_data_api), + url(r'^api/calendars/topics/holiday/?$', reader_views.next_holiday), url(r'^api/name/(?P.+)$', reader_views.name_api), url(r'^api/category/?(?P.+)?$', reader_views.category_api), url(r'^api/tag-category/?(?P.+)?$', reader_views.tag_category_api), @@ -263,6 +265,7 @@ url(r'^api/topics$', reader_views.topics_list_api), url(r'^api/topics/generate-prompts/(?P.+)$', reader_views.generate_topic_prompts_api), url(r'^api/topics-graph/(?P.+)$', reader_views.topic_graph_api), + url(r'^_api/topics/seasonal-topic/?$', reader_views.seasonal_topic_api), url(r'^api/topics/pools/(?P.+)$', reader_views.topic_pool_api), url(r'^api/ref-topic-links/bulk$', reader_views.topic_ref_bulk_api), url(r'^api/ref-topic-links/(?P.+)$', reader_views.topic_ref_api), diff --git a/sefaria/utils/calendars.py b/sefaria/utils/calendars.py index 8f416a3683..7f3a596e90 100644 --- a/sefaria/utils/calendars.py +++ b/sefaria/utils/calendars.py @@ -282,17 +282,15 @@ def make_haftarah_response_from_calendar_entry(db_parasha, custom=None): return haftarah_objs -def make_parashah_response_from_calendar_entry(db_parasha): +def make_parashah_response_from_calendar_entry(db_parasha, include_topic_slug=False): rf = model.Ref(db_parasha["ref"]) - parashiot = db_parasha["parasha"].split("-") # Could be a double parashah - p_en, p_he = [], [] - for p in parashiot: - parasha_topic = model.Topic().load({"parasha": p}) - if parasha_topic: - p_en.append(parasha_topic.description["en"]) - p_he.append(parasha_topic.description["he"]) - parasha_description = {"en": "\n\n".join(p_en), "he": "\n\n".join(p_he)} + parasha_topic = model.Topic().load({"parasha": db_parasha['parasha']}) + p_en = p_he = "" + if parasha_topic: + p_en = parasha_topic.description["en"] + p_he = parasha_topic.description["he"] + parasha_description = {"en": p_en, "he": p_he} parasha = { 'title': {'en': 'Parashat Hashavua', 'he': 'פרשת השבוע'}, @@ -305,6 +303,8 @@ def make_parashah_response_from_calendar_entry(db_parasha): 'extraDetails': {'aliyot': db_parasha["aliyot"]}, 'description': parasha_description } + if include_topic_slug and parasha_topic: + parasha['topic'] = parasha_topic.slug # include the slug so that the Sheets Homepage has access to the parasha's topic link return [parasha] diff --git a/sefaria/views.py b/sefaria/views.py index a33a385dbc..d21eae8924 100644 --- a/sefaria/views.py +++ b/sefaria/views.py @@ -474,7 +474,7 @@ def bulktext_api(request, refs): g = lambda x: request.GET.get(x, None) min_char = int(g("minChar")) if g("minChar") else None max_char = int(g("maxChar")) if g("maxChar") else None - res = bundle_many_texts(refs, int(g("useTextFamily")), g("asSizedString"), min_char, max_char, g("transLangPref"), g("ven"), g("vhe")) + res = bundle_many_texts(refs, g("useTextFamily"), g("asSizedString"), min_char, max_char, g("transLangPref"), g("ven"), g("vhe")) resp = jsonResponse(res, cb) return resp diff --git a/static/css/s2.css b/static/css/s2.css index bfd4663b15..199afe1a18 100644 --- a/static/css/s2.css +++ b/static/css/s2.css @@ -2795,7 +2795,42 @@ display: none; color: var(--light-grey); } -.navBlockTitle .int-he, .navBlockDescription .int-he{ +div.sheetsWrapper :nth-child(1 of .card) { + border-top: 1px solid #EDEDEC; +} +.card { + flex: 1 1 45%; + position: relative; + padding-bottom: 12px; +} +.cardTitle { + text-align: start; + font-size: 24px; + padding: 15px 0 8px; + margin: 0; + cursor: pointer; + display: flex; + align-items: center; + line-height: 1.3; + flex: 1; + font-weight: 400; +} +a.cardTitle:hover { + text-decoration: none; + color: var(--dark-grey); +} +.cardDescription { + font-size: 14px; + line-height: 18px; + color: #666; + margin-inline-end: 20px; + --english-font: var(--english-sans-serif-font-family); + --hebrew-font: var(--hebrew-sans-serif-font-family); +} +.hebrew .cardDescription { + line-height: 24px; +} +.navBlockTitle .int-he, .navBlockDescription .int-he, .cardTitle .int-he, .cardDescription .int-he{ font-size: 122%; } .interface-english .navBlockDescription.clamped .int-en, @@ -4969,7 +5004,100 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus .no-wrapping-salad-item-container{ white-space: nowrap; } +.topic-landing-temporal{ + display: flex; + margin-top: 30px; +} + +.topic-landing-parasha{ + +} +.topic-landing-seasonal{ +} +.topic-landing-parasha .browse-all-parashot-prompt{ + color: var(--Commentary-Blue, #4B71B7); + margin-top: 14px +} +.topic-landing-parasha .browse-all-parashot-prompt span{ + font-family: Roboto; + font-size: 14px; +} +.topic-landing-parasha .read-portion-button{ + margin-top: 30px; +} +.topic-landing-temporal > .topic-landing-parasha { + border-inline-end: 1px solid #ccc; + padding-inline-end: 67px; + flex: 1; +} +.topic-landing-temporal > .topic-landing-seasonal { + padding-inline-start: 67px; + flex: 1; +} + + +.topic-landing-calendar .calendar-header{ + color: var(--Dark-Grey, #666); + --english-font: var(--english-sans-serif-font-family); + --hebrew-font: var(--hebrew-sans-serif-font-family); + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: normal; +} + +.topic-landing-seasonal .explore-calendar-prompt{ + color: var(--Commentary-Blue, #4B71B7); + margin-top: 14px; +} +.topic-landing-seasonal .explore-calendar-prompt span{ + font-family: Roboto; + font-size: 14px; +} + +.topic-landing-temporal .learn-more-prompt{ + font-size: 14px; + line-height: 18px; + color: #666; + margin-inline-end: 20px; + --english-font: var(--english-sans-serif-font-family); + --hebrew-font: var(--hebrew-sans-serif-font-family); +} +.topic-landing-temporal .parashah-link{ + margin-top: 47px; +} +.topic-landing-parasha .parasha-link .navSidebarLink.ref span{ + --english-font: var(--english-sans-serif-font-family); + --hebrew-font: var(--hebrew-sans-serif-font-family); +} +.topic-landing-temporal .display-date-message{ + color: var(--Darkest-Grey, #333); + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 18px; + margin-top: 55px; +} +.topic-landing-temporal .display-date-message span { + font-family: Roboto; +} +.topic-landing-temporal .display-date-message a { + font-family: Roboto; +} +.topic-landing-temporal .display-date{ + font-size: 14px; + font-style: normal; + font-weight: 400; + font-family: Roboto; + margin-top: 19px; +} +.topic-landing-temporal .display-date span{ + font-family: 'Roboto'; +} +.topic-landing-parasha .navSidebarLink span{ + font-family: Roboto, sans-serif; +} .readerNavMenu .sheet { display: flex; justify-content: space-between; diff --git a/static/js/AboutSheet.jsx b/static/js/AboutSheet.jsx index 0ee8cd0860..0d24d0f2d4 100644 --- a/static/js/AboutSheet.jsx +++ b/static/js/AboutSheet.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; -import {SheetAuthorStatement, InterfaceText, ProfilePic, EnglishText, HebrewText} from "./Misc"; +import {SheetAuthorStatement, InterfaceText, EnglishText, HebrewText} from "./Misc"; +import {ProfilePic} from "./ProfilePic"; import Sefaria from "./sefaria/sefaria"; import ReactTags from 'react-tag-autocomplete' import { useDebounce } from "./Hooks"; diff --git a/static/js/CollectionPage.jsx b/static/js/CollectionPage.jsx index 5fb3bfe07a..5ead3f1ee4 100644 --- a/static/js/CollectionPage.jsx +++ b/static/js/CollectionPage.jsx @@ -15,12 +15,12 @@ import { InterfaceText, LanguageToggleButton, LoadingMessage, - ProfilePic, ResponsiveNBox, SheetListing, TabView, TwoOrThreeBox, } from './Misc'; +import {ProfilePic} from "./ProfilePic"; import {ContentText} from "./ContentText"; diff --git a/static/js/ConnectionsPanel.jsx b/static/js/ConnectionsPanel.jsx index 039b3eadf3..0e740ffa79 100644 --- a/static/js/ConnectionsPanel.jsx +++ b/static/js/ConnectionsPanel.jsx @@ -8,7 +8,6 @@ import { SheetListing, Note, FeedbackBox, - ProfilePic, DivineNameReplacer, ToolTipped, InterfaceText, EnglishText, HebrewText, } from './Misc'; diff --git a/static/js/Editor.jsx b/static/js/Editor.jsx index f5e3fd7d15..b94f22e660 100644 --- a/static/js/Editor.jsx +++ b/static/js/Editor.jsx @@ -13,10 +13,10 @@ import { SheetAuthorStatement, SheetTitle, CollectionStatement, - ProfilePic, InterfaceText, Autocompleter, } from './Misc'; +import {ProfilePic} from "./ProfilePic"; import classNames from 'classnames'; import $ from "./sefaria/sefariaJquery"; diff --git a/static/js/Header.jsx b/static/js/Header.jsx index d44cb5cf9a..5a4d8f53f4 100644 --- a/static/js/Header.jsx +++ b/static/js/Header.jsx @@ -8,13 +8,13 @@ import Sefaria from './sefaria/sefaria'; import { SearchButton, GlobalWarningMessage, - ProfilePic, InterfaceLanguageMenu, InterfaceText, LanguageToggleButton, DonateLink } from './Misc'; import {HeaderAutocomplete} from './HeaderAutocomplete' +import {ProfilePic} from "./ProfilePic"; class Header extends Component { constructor(props) { diff --git a/static/js/ImageCropper.jsx b/static/js/ImageCropper.jsx new file mode 100644 index 0000000000..b65955b407 --- /dev/null +++ b/static/js/ImageCropper.jsx @@ -0,0 +1,117 @@ +import React, {useState, useRef} from 'react'; +import Sefaria from './sefaria/sefaria'; +import ReactCrop from 'react-image-crop'; +import {LoadingRing} from "./Misc"; +import 'react-image-crop/dist/ReactCrop.css'; + +export const ImageCropper = ({loading, error, src, onClose, onSave}) => { + const imageRef = useRef(null); + const [isFirstCropChange, setIsFirstCropChange] = useState(true); + const [crop, setCrop] = useState({unit: "px", width: 250, aspect: 1}); + const [croppedImageBlob, setCroppedImageBlob] = useState(null); + const onImageLoaded = (image) => {imageRef.current = image}; + const onCropComplete = (crop) => { + makeClientCrop(crop); + } + const onCropChange = (crop, percentCrop) => { + if (isFirstCropChange) { + const { clientWidth:width, clientHeight:height } = imageRef.current; + crop.width = Math.min(width, height); + crop.height = crop.width; + crop.x = (imageRef.current.width/2) - (crop.width/2); + crop.y = (imageRef.current.height/2) - (crop.width/2); + setCrop(crop); + setIsFirstCropChange(false); + } else { + setCrop(crop); + } + } + const makeClientCrop = async (crop) => { + if (imageRef.current && crop.width && crop.height) { + const croppedImageBlob = await getCroppedImg( + imageRef.current, + crop, + "newFile.jpeg" + ); + setCroppedImageBlob(croppedImageBlob); + } + } + const getCroppedImg = (image, crop, fileName) => { + const canvas = document.createElement("canvas"); + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + canvas.width = crop.width * scaleX; + canvas.height = crop.height * scaleY; + const ctx = canvas.getContext("2d"); + ctx.drawImage( + image, + crop.x * scaleX, + crop.y * scaleY, + crop.width * scaleX, + crop.height * scaleY, + 0, + 0, + crop.width * scaleX, + crop.height * scaleY + ); + + return new Promise((resolve, reject) => { + canvas.toBlob(blob => { + if (!blob) { + console.error("Canvas is empty"); + return; + } + blob.name = fileName; + resolve(blob); + }, "image/jpeg"); + }); + } + const closePopup = () => { + setCrop({unit: "px", width: 250, aspect: 1}); + setIsFirstCropChange(true); + setCroppedImageBlob(null); + onClose(); + } + return (<> + { (src || !!error) && ( +
+
+
+
+ { src ? + () : (
{ error }
) + } +
+ { (loading || isFirstCropChange) ? (
) : ( +
+
+ Drag corners to crop image + לחיתוך התמונה, גרור את הפינות +
+ +
+ ) + } +
+
+ ) + } + ); +}; diff --git a/static/js/Misc.jsx b/static/js/Misc.jsx index bc988e2ae8..595096c1aa 100644 --- a/static/js/Misc.jsx +++ b/static/js/Misc.jsx @@ -9,11 +9,10 @@ import PropTypes from 'prop-types'; import Component from 'react-class'; import { usePaginatedDisplay } from './Hooks'; import {ContentLanguageContext, AdContext, StrapiDataContext} from './context'; -import ReactCrop from 'react-image-crop'; -import 'react-image-crop/dist/ReactCrop.css'; import {ContentText} from "./ContentText"; import ReactTags from "react-tag-autocomplete"; import {AdminEditorButton, useEditToggle} from "./AdminEditor"; +import {ProfilePic} from "./ProfilePic"; import {CategoryEditor, ReorderEditor} from "./CategoryEditor"; import {refSort} from "./TopicPage"; import {TopicEditor} from "./TopicEditor"; @@ -141,226 +140,6 @@ const DonateLink = ({children, classes, source, link}) => { ); }; -/* flexible profile picture that overrides the default image of gravatar with text with the user's initials */ -class ProfilePic extends Component { - constructor(props) { - super(props); - this.state = { - showDefault: !this.props.url || this.props.url.startsWith("https://www.gravatar"), // We can't know in advance if a gravatar image exists of not, so start with the default beforing trying to load image - src: null, - isFirstCropChange: true, - crop: {unit: "px", width: 250, aspect: 1}, - croppedImageBlob: null, - error: null, - }; - this.imgFile = React.createRef(); - } - setShowDefault() { /* console.log("error"); */ this.setState({showDefault: true}); } - setShowImage() { /* console.log("load"); */ this.setState({showDefault: false}); } - componentDidMount() { - if (this.didImageLoad()) { - this.setShowImage(); - } else { - this.setShowDefault(); - } - } - didImageLoad(){ - // When using React Hydrate, the onLoad event of the profile image will return before - // react code runs, so we check after mount as well to look replace bad images, or to - // swap in a gravatar image that we now know is valid. - const img = this.imgFile.current; - return (img && img.complete && img.naturalWidth !== 0); - } - onSelectFile(e) { - if (e.target.files && e.target.files.length > 0) { - if (!e.target.files[0].type.startsWith('image/')) { - this.setState({ error: "Error: Please upload an image with the correct file extension (e.g. jpg, png)"}); - return; - } - const reader = new FileReader(); - reader.addEventListener("load", () => - this.setState({ src: reader.result }) - ); - console.log("FILE", e.target.files[0]); - reader.readAsDataURL(e.target.files[0]); - } - } - onImageLoaded(image) { - this.imageRef = image; - } - onCropComplete(crop) { - this.makeClientCrop(crop); - } - onCropChange(crop, percentCrop) { - // You could also use percentCrop: - // this.setState({ crop: percentCrop }); - if (this.state.isFirstCropChange) { - const { clientWidth:width, clientHeight:height } = this.imageRef; - crop.width = Math.min(width, height); - crop.height = crop.width; - crop.x = (this.imageRef.width/2) - (crop.width/2); - crop.y = (this.imageRef.height/2) - (crop.width/2); - this.setState({ crop, isFirstCropChange: false }); - } else { - this.setState({ crop }); - } - } - async makeClientCrop(crop) { - if (this.imageRef && crop.width && crop.height) { - const croppedImageBlob = await this.getCroppedImg( - this.imageRef, - crop, - "newFile.jpeg" - ); - //console.log(croppedImageUrl); - this.setState({ croppedImageBlob }); - } - } - getCroppedImg(image, crop, fileName) { - const canvas = document.createElement("canvas"); - const scaleX = image.naturalWidth / image.width; - const scaleY = image.naturalHeight / image.height; - canvas.width = crop.width * scaleX; - canvas.height = crop.height * scaleY; - const ctx = canvas.getContext("2d"); - ctx.drawImage( - image, - crop.x * scaleX, - crop.y * scaleY, - crop.width * scaleX, - crop.height * scaleY, - 0, - 0, - crop.width * scaleX, - crop.height * scaleY - ); - - return new Promise((resolve, reject) => { - canvas.toBlob(blob => { - if (!blob) { - console.error("Canvas is empty"); - return; - } - blob.name = fileName; - resolve(blob); - }, "image/jpeg"); - }); - } - closePopup({ cb }) { - this.setState({ - src: null, - crop: {unit: "px", width: 250, aspect: 1}, - isFirstCropChange: true, - croppedImageBlob: null, - error: null, - }, cb); - } - async upload() { - const formData = new FormData(); - formData.append('file', this.state.croppedImageBlob); - this.setState({ uploading: true }); - let errored = false; - try { - const response = await Sefaria.uploadProfilePhoto(formData); - if (response.error) { - throw new Error(response.error); - } else { - this.closePopup({ cb: () => { - window.location = "/profile/" + Sefaria.slug; // reload to get update - return; - }}); - } - } catch (e) { - errored = true; - console.log(e); - } - this.setState({ uploading: false, errored }); - } - render() { - const { name, url, len, hideOnDefault, showButtons, outerStyle } = this.props; - const { showDefault, src, crop, error, uploading, isFirstCropChange } = this.state; - const nameArray = !!name.trim() ? name.trim().split(/\s/) : []; - const initials = nameArray.length > 0 ? (nameArray.length === 1 ? nameArray[0][0] : nameArray[0][0] + nameArray[nameArray.length-1][0]) : ""; - const defaultViz = showDefault ? 'flex' : 'none'; - const profileViz = showDefault ? 'none' : 'block'; - const imageSrc = url.replace("profile-default.png", 'profile-default-404.png'); // replace default with non-existant image to force onLoad to fail - - return ( -
-
- { showButtons ? null : `${initials}` } -
- User Profile Picture - {this.props.children ? this.props.children : null /*required for slate.js*/} - { showButtons ? /* cant style file input directly. see: https://stackoverflow.com/questions/572768/styling-an-input-type-file-button */ - (
- { event.target.value = null}}/> - -
) : null - } - { (src || !!error) && ( -
-
-
-
- { src ? - () : (
{ error }
) - } -
- { (uploading || isFirstCropChange) ? (
) : ( -
-
- Drag corners to crop image - לחיתוך התמונה, גרור את הפינות -
- -
- ) - } -
-
- ) - } -
- ); - } -} -ProfilePic.propTypes = { - url: PropTypes.string, - name: PropTypes.string, - len: PropTypes.number, - hideOnDefault: PropTypes.bool, // hide profile pic if you have are displaying default pic - showButtons: PropTypes.bool, // show profile pic action buttons -}; /** @@ -3507,7 +3286,6 @@ export { NBox, Note, ProfileListing, - ProfilePic, ReaderMessage, CloseButton, DisplaySettingsButton, diff --git a/static/js/NavSidebar.jsx b/static/js/NavSidebar.jsx index 38d0355549..11aac41b3b 100644 --- a/static/js/NavSidebar.jsx +++ b/static/js/NavSidebar.jsx @@ -938,5 +938,6 @@ const PortalNewsletter = ({title, description}) => { export { NavSidebar, SidebarModules, - RecentlyViewed + RecentlyViewed, + ParashahLink }; diff --git a/static/js/ProfilePic.jsx b/static/js/ProfilePic.jsx new file mode 100644 index 0000000000..701e4b13bc --- /dev/null +++ b/static/js/ProfilePic.jsx @@ -0,0 +1,120 @@ +import React from 'react'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import Component from 'react-class'; +import {ImageCropper} from "./ImageCropper"; + +/* flexible profile picture that overrides the default image of gravatar with text with the user's initials */ +export class ProfilePic extends Component { + constructor(props) { + super(props); + this.state = { + showDefault: !this.props.url || this.props.url.startsWith("https://www.gravatar"), // We can't know in advance if a gravatar image exists of not, so start with the default beforing trying to load image + fileToCropSrc: null, + uploadError: null, + uploading: false, + }; + this.imgFile = React.createRef(); + } + setShowDefault() { /* console.log("error"); */ this.setState({showDefault: true}); } + setShowImage() { /* console.log("load"); */ this.setState({showDefault: false}); } + componentDidMount() { + if (this.didImageLoad()) { + this.setShowImage(); + } else { + this.setShowDefault(); + } + } + didImageLoad(){ + // When using React Hydrate, the onLoad event of the profile image will return before + // react code runs, so we check after mount as well to look replace bad images, or to + // swap in a gravatar image that we now know is valid. + const img = this.imgFile.current; + return (img && img.complete && img.naturalWidth !== 0); + } + onSelectFile(e) { + if (e.target.files && e.target.files.length > 0) { + if (!e.target.files[0].type.startsWith('image/')) { + this.setState({uploadError: "Error: Please upload an image with the correct file extension (e.g. jpg, png)"}) + return; + } + const reader = new FileReader(); + reader.addEventListener("load", () => this.setState({fileToCropSrc: reader.result})); + console.log("FILE", e.target.files[0]); + reader.readAsDataURL(e.target.files[0]); + } + } + onCloseCropper() { + this.setState({fileToCropSrc: null, uploadError: null, uploading: false}); + } + async upload(croppedImageBlob) { + const formData = new FormData(); + formData.append('file', croppedImageBlob); + this.setState({ uploading: true }); + console.log("uploading") + try { + const response = await Sefaria.uploadProfilePhoto(formData); + console.log('responsse', response) + if (response.error) { + throw new Error(response.error); + } else { + window.location = "/profile/" + Sefaria.slug; // reload to get update + return; + } + } catch (e) { + this.setState({fileToCropSrc: null, uploadError: "Upload Error. Please contact hello@sefaria.org for assistance."}) + } + this.setState({ uploading: false }); + } + render() { + const { name, url, len, hideOnDefault, showButtons, outerStyle } = this.props; + const { showDefault} = this.state; + const nameArray = !!name.trim() ? name.trim().split(/\s/) : []; + const initials = nameArray.length > 0 ? (nameArray.length === 1 ? nameArray[0][0] : nameArray[0][0] + nameArray[nameArray.length-1][0]) : ""; + const defaultViz = showDefault ? 'flex' : 'none'; + const profileViz = showDefault ? 'none' : 'block'; + const imageSrc = url.replace("profile-default.png", 'profile-default-404.png'); // replace default with non-existant image to force onLoad to fail + + return ( +
+
+ { showButtons ? null : `${initials}` } +
+ User Profile Picture + {this.props.children ? this.props.children : null /*required for slate.js*/} + { showButtons ? /* cant style file input directly. see: https://stackoverflow.com/questions/572768/styling-an-input-type-file-button */ + (
+ { event.target.value = null}}/> + +
) : null + } + +
+ ); + } +} +ProfilePic.propTypes = { + url: PropTypes.string, + name: PropTypes.string, + len: PropTypes.number, + hideOnDefault: PropTypes.bool, // hide profile pic if you have are displaying default pic + showButtons: PropTypes.bool, // show profile pic action buttons +}; diff --git a/static/js/SearchSheetResult.jsx b/static/js/SearchSheetResult.jsx index 821dc3fd94..52e556f0e8 100644 --- a/static/js/SearchSheetResult.jsx +++ b/static/js/SearchSheetResult.jsx @@ -6,8 +6,8 @@ import PropTypes from 'prop-types'; import Component from 'react-class'; import { ColorBarBox, InterfaceText, - ProfilePic, } from './Misc'; +import {ProfilePic} from "./ProfilePic"; class SearchSheetResult extends Component { diff --git a/static/js/Sheet.jsx b/static/js/Sheet.jsx index 95b94fb047..7696f41806 100644 --- a/static/js/Sheet.jsx +++ b/static/js/Sheet.jsx @@ -15,8 +15,8 @@ import { SheetAuthorStatement, SheetTitle, CollectionStatement, - ProfilePic, } from './Misc'; +import {ProfilePic} from "./ProfilePic"; class Sheet extends Component { diff --git a/static/js/SheetMetadata.jsx b/static/js/SheetMetadata.jsx index 6da073db68..0cf2c31ee0 100644 --- a/static/js/SheetMetadata.jsx +++ b/static/js/SheetMetadata.jsx @@ -7,9 +7,8 @@ import { LoadingMessage, LoginPrompt, SheetAuthorStatement, - ProfilePic, - } from './Misc'; +import {ProfilePic} from "./ProfilePic"; import { CollectionsModal } from './CollectionsWidget' import React from 'react'; import ReactDOM from 'react-dom'; diff --git a/static/js/TopicLandingPage/TopicLandingCalendar.jsx b/static/js/TopicLandingPage/TopicLandingCalendar.jsx new file mode 100644 index 0000000000..a09283eb3d --- /dev/null +++ b/static/js/TopicLandingPage/TopicLandingCalendar.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import {InterfaceText} from "../Misc"; +import {Card} from "../common/Card"; + + +export const TopicLandingCalendar = ({ header, title, description, link, children }) => { + return ( +
+
+ {header} +
+ + {children &&
{children}
} +
+ ); +}; \ No newline at end of file diff --git a/static/js/TopicLandingPage/TopicLandingParasha.jsx b/static/js/TopicLandingPage/TopicLandingParasha.jsx new file mode 100644 index 0000000000..dbf2d9f79d --- /dev/null +++ b/static/js/TopicLandingPage/TopicLandingParasha.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {TopicLandingCalendar} from "./TopicLandingCalendar"; +import {useState, useEffect} from "react"; +import {ParashahLink} from "../NavSidebar"; +import {InterfaceText} from "../Misc"; +import Sefaria from "../sefaria/sefaria"; + + +export const TopicLandingParasha = () => { + const [parashah, setParashah] = useState({}); + + useEffect(() => { + Sefaria.getUpcomingDay('parasha').then(setParashah); + }, []); + + const parashahTitle = parashah.displayValue; + const parashahDesc = parashah.description; + const parashahTopicLink = `topics/${parashah?.topic}`; + const parashahRefLink = `/${parashah?.url}`; + const learnMorePrompt = {en: `Learn More about ${parashahTitle?.en} ›`, + he:`${Sefaria._("Learn More about")} ${parashahTitle?.he} ›`} + + + return ( +
+ This Week’s Torah Portion} + title={parashahTitle} + description={parashahDesc} + link={parashahTopicLink} + > +
+ + + +
+
+ +
+ + +
+
+ ); +}; \ No newline at end of file diff --git a/static/js/TopicLandingPage/TopicLandingSeasonal.jsx b/static/js/TopicLandingPage/TopicLandingSeasonal.jsx new file mode 100644 index 0000000000..e671ba81e5 --- /dev/null +++ b/static/js/TopicLandingPage/TopicLandingSeasonal.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import {TopicLandingCalendar} from "./TopicLandingCalendar"; +import {useState, useEffect} from "react"; +import Sefaria from "../sefaria/sefaria"; +import {InterfaceText} from "../Misc"; + +const createDisplayDateMessage =(displayDatePrefix, link, secondaryTopicTitleString, displayDateSuffix)=> { + return ( + <> + {displayDatePrefix ?? ''}{' '} + {secondaryTopicTitleString ? {secondaryTopicTitleString} : ''}{' '} + {displayDateSuffix ?? ''} + + ); +} + +const useSeasonalTopic = () => { + const [seasonal, setSeasonal] = useState(null); + + useEffect(() => { + Sefaria.getSeasonalTopic().then(setSeasonal); + }, []); + + if (!seasonal) return { isLoading: true }; + + const title = seasonal.topic?.primaryTitle; + const description = seasonal.topic?.description; + const link = `/topics/${seasonal.topic?.slug}`; + + const displayStartDate = new Date(seasonal.display_start_date); + const displayEndDate = new Date(seasonal.display_end_date); + const displayDatePrefix = seasonal.display_date_prefix || ''; + const displayDateSuffix = seasonal.display_date_suffix || ''; + const secondaryTopicTitle = seasonal.secondary_topic?.primaryTitle || null; + const secondaryTopicSlug = seasonal.secondary_topic?.slug || null; + + + return { + title, + description, + link, + displayStartDate, + displayEndDate, + displayDateSuffix, + displayDatePrefix, + secondaryTopicTitle, + secondaryTopicSlug, + isLoading: false, + }; +}; + +export const TopicLandingSeasonal = () => { + + const { + title, + description, + link, + displayStartDate, + displayEndDate, + displayDateSuffix, + displayDatePrefix, + secondaryTopicTitle, + secondaryTopicSlug, + isLoading, + } = useSeasonalTopic(); + if (isLoading) return null; + const displayDateMessageEn = createDisplayDateMessage(displayDatePrefix, link, secondaryTopicTitle?.en, displayDateSuffix); + const displayDateMessageHe = createDisplayDateMessage(displayDatePrefix, link, secondaryTopicTitle?.he, displayDateSuffix); + const enDateFormat = new Intl.DateTimeFormat("en", { + month: "long", + day: "numeric", + }); + const heDateFormat = new Intl.DateTimeFormat("he", { + month: "long", + day: "numeric", + }); + + const formattedDateEn = secondaryTopicSlug && enDateFormat.formatRange(displayStartDate, displayEndDate); + const formattedDateHe = secondaryTopicSlug && heDateFormat.formatRange(displayStartDate, displayEndDate); + const learnMorePrompt = {en: `Learn More on ${title?.en} ›`, + he:`${Sefaria._("Learn More on")} ${title?.he} ›`} + + + return ( +
+ Upcoming Holiday on the Jewish Calendar} + title={title} + description={description} + link={link} + /> +
+ + + +
+
+ +
+
+ +
+ +
+ ); +}; \ No newline at end of file diff --git a/static/js/TopicLandingPage/TopicsLandingPage.jsx b/static/js/TopicLandingPage/TopicsLandingPage.jsx index f235e22cfc..4d960fed94 100644 --- a/static/js/TopicLandingPage/TopicsLandingPage.jsx +++ b/static/js/TopicLandingPage/TopicsLandingPage.jsx @@ -4,6 +4,10 @@ import {NavSidebar} from "../NavSidebar"; import Footer from "../Footer"; import {TopicSalad} from "./TopicSalad"; import {RainbowLine} from "../RainbowLine"; +import {TopicLandingCalendar} from "./TopicLandingCalendar"; +import {TopicLandingParasha} from "./TopicLandingParasha"; +import Search from "../sefaria/search"; +import {TopicLandingSeasonal} from "./TopicLandingSeasonal"; export const TopicsLandingPage = ({openTopic}) => { @@ -17,6 +21,10 @@ export const TopicsLandingPage = ({openTopic}) => {
+
+ + +
diff --git a/static/js/UserProfile.jsx b/static/js/UserProfile.jsx index 5476e6ab7d..5ea5e4e400 100644 --- a/static/js/UserProfile.jsx +++ b/static/js/UserProfile.jsx @@ -11,10 +11,10 @@ import { TabView, SheetListing, ProfileListing, - ProfilePic, FollowButton, InterfaceText, } from './Misc'; +import {ProfilePic} from "./ProfilePic"; import { SignUpModalKind } from './sefaria/signupModalContent'; class UserProfile extends Component { diff --git a/static/js/common/Card.jsx b/static/js/common/Card.jsx new file mode 100644 index 0000000000..d4538ec5fa --- /dev/null +++ b/static/js/common/Card.jsx @@ -0,0 +1,14 @@ +import {InterfaceText} from "../Misc"; +import React from "react"; +const Card = ({cardTitle, cardTitleHref, oncardTitleClick, cardText}) => { + return
+ + + +
+ +
+
+} + +export { Card } diff --git a/static/js/sefaria/sefaria.js b/static/js/sefaria/sefaria.js index 3cc2fe3881..30adc81ef5 100644 --- a/static/js/sefaria/sefaria.js +++ b/static/js/sefaria/sefaria.js @@ -2663,6 +2663,18 @@ _media: {}, stories: [], page: 0 }, + _upcomingDay: {}, // for example, possible keys are 'parasha' and 'holiday' + getUpcomingDay: function(day) { + // currently `day` can be 'holiday' or 'parasha' + if (day !== 'holiday' && day !== 'parasha') { + throw new Error('Invalid day. Expected "holiday" or "parasha".'); + } + return this._cachedApiPromise({ + url: `${this.apiHost}/api/calendars/topics/${day}`, + key: day, + store: this._upcomingDay, + }); + }, _parashaNextRead: {}, getParashaNextRead: function(parasha) { return this._cachedApiPromise({ @@ -2768,6 +2780,14 @@ _media: {}, const key = this._getTopicCacheKey(slug, {annotated, with_html}); return this._topics[key]; }, + _seasonalTopic: {}, + getSeasonalTopic: function() { + return this._cachedApiPromise({ + url: `${Sefaria.apiHost}/_api/topics/seasonal-topic?lang=${Sefaria.interfaceLang.slice(0, 2)}`, + key: (new Date()).toLocaleDateString(), + store: this._seasonalTopic, + }); + }, _topicSlugsToTitles: null, slugsToTitles: function() { //initializes _topicSlugsToTitles for Topic Editor tool and adds necessary "Choose a Category" and "Main Menu" for diff --git a/static/js/sefaria/strings.js b/static/js/sefaria/strings.js index 9b607b81f5..4e517ae247 100644 --- a/static/js/sefaria/strings.js +++ b/static/js/sefaria/strings.js @@ -548,6 +548,13 @@ const Strings = { "Jewish Encyclopedia": "האנציקלופדיה היהודית", "National Library of Israel": "הספרייה הלאומית", "Works on Sefaria": "חיבורים וכתבים בספריא", + "Learn More on Parashat": "לקריאה נוספת על פרשת", + "This Week’s Torah Portion": "פרשת השבוע", + "Learn More on": "לקריאה נוספת על", + "Learn More about": "לקריאה נוספת על", + "Read the Portion": "לקריאת הפרשה", + "Browse all Parshayot": "לצפייה בכל הפרשות", + "Upcoming Holiday on the Jewish Calendar": "החג הבא בלוח השנה", //Module Names "Download Text": "הורדת טקסט",