diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..c0246cac --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,17 @@ +module.exports = { + extends: ["universe/native"], + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + plugins: ["babel", "react"], + rules: { + "prettier/prettier": [ + "error", + { + endOfLine: "auto", + printWidth: 120, + }, + ], + }, +}; diff --git a/.gitignore b/.gitignore index ef5b566c..982d0b45 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,6 @@ buck-out/ .expo/ web-build/ -# @end expo-cli \ No newline at end of file +# @end expo-cli + +out \ No newline at end of file diff --git a/App.js b/App.js deleted file mode 100644 index 4d61827a..00000000 --- a/App.js +++ /dev/null @@ -1,196 +0,0 @@ -import React, { useState, useEffect, useRef } from "react" -import { Appearance, Image, Linking, TouchableOpacity } from "react-native" -import { SafeAreaProvider } from "react-native-safe-area-context" -import { navigate, logoAssets } from "./navigation" -import * as Font from "expo-font" -import * as Device from "expo-device" -import * as Notifications from "expo-notifications" -import { Strings, Fonts } from "./utils/constants" -import * as eva from "@eva-design/eva" -import { ApplicationProvider, Icon, IconRegistry, Text } from "@ui-kitten/components" -import { EvaIconsPack } from "@ui-kitten/eva-icons" -import { DailyBread as bread } from "./theme" -import { default as mapping } from "./mapping.json" -import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native" -import { createStackNavigator } from "@react-navigation/stack" -import { ThemeContext } from "./theme-context" -import { decode } from "html-entities" -import Model from "./utils/model" -import { APIKEY, MESSAGING_SENDER_ID, APP_ID, MEASUREMENT_ID, SERVICE_ACCOUNT_ID, TECH_PASSWORD } from "@env" -import { Author, Home, Post, Section, Search } from "./components/screens" -import { getMostCommonTagsFromRecentPosts } from "./utils/format" -import { enableAnimationExperimental, onShare, registerForPushNotificationsAsync } from "./utils/action" - -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: false, - shouldSetBadge: false - }) -}) - -const firebaseConfig = { - apiKey: APIKEY, - authDomain: "daily-mobile-app-notifications.firebaseapp.com", - databaseURL: "https://daily-mobile-app-notifications-default-rtdb.firebaseio.com", - projectId: "daily-mobile-app-notifications", - storageBucket: "daily-mobile-app-notifications.appspot.com", - messagingSenderId: MESSAGING_SENDER_ID, - appId: APP_ID, - measurementId: MEASUREMENT_ID, - serviceAccountId: SERVICE_ACCOUNT_ID -} - -const Stack = createStackNavigator() -enableAnimationExperimental() - -export default function App() { - const [fontsLoaded, setFontsLoaded] = useState(false) - const [expoPushToken, setExpoPushToken] = useState("") - const [notification, setNotification] = useState(false) - const notificationListener = useRef() - const responseListener = useRef() - const colorScheme = Appearance.getColorScheme() - const [theme, setTheme] = useState(colorScheme) - const [deviceType, setDeviceType] = useState(Device.DeviceType.PHONE) - const [searchVisible, setSearchVisible] = useState(false) - const [configValidated, setConfigValidated] = useState(false) - const [tags, setTags] = useState([]) - - const toggleTheme = () => { - const next = theme === "light" ? "dark" : "light" - setTheme(next) - } - - const navigatorTheme = { - light: DefaultTheme, - dark: DarkTheme - } - - const headerOptions = ({ navigation, route }) => { - return { - headerTitle: () => ( - - ), - headerRight: () => !searchVisible && ( - navigation.navigate(Strings.search, { tags })}> - - - ) - }; - }; - - const detailHeaderOptions = ({ navigation, route }) => { - return { - headerTitle: "", - headerTransparent: true, - headerTintColor: "white", - headerBackTitleVisible: false, - headerRight: () => ( - onShare(route.params.article.link, decode(route.params.article.title.rendered))}> - - - ) - } - } - - const sectionOptions = ({ route }) => ({ - headerTitle: () => {decode(route.params.category.name).replace("'", "\u{2019}")}, - headerTitleStyle: { fontFamily: "MinionProBold" }, - headerTintColor: bread[theme]["color-primary-500"] - }) - - const authorOptions = ({ route }) => ({ - headerTitle: () => {route.params.name}, - headerTitleStyle: { fontFamily: "MinionProBold" }, - headerTintColor: bread[theme]["color-primary-500"] - }) - - const searchHeaderOptions = { - headerTintColor: bread[theme]["color-primary-500"] - } - - useEffect(() => { - // Loads fonts from static resource. - Font.loadAsync(Fonts.minion).then(() => setFontsLoaded(true)) - - /*if (firebase) { - registerForPushNotificationsAsync().then(token => { - setExpoPushToken(token) - var matches = token?.match(/\[(.*?)\]/) - - if (matches) { - var submatch = matches[1] - signInWithEmailAndPassword(firebase.auth, "tech@stanforddaily.com", TECH_PASSWORD).then((userCredential) => { - const tokenRef = ref(firebase.db, "ExpoPushTokens/" + submatch, userCredential) - set(tokenRef, new Date().toISOString()).catch(error => console.log(error)) - }).catch(error => console.trace(error)) - } - }) - }*/ - - Device.getDeviceTypeAsync().then(type => setDeviceType(type)) - - getMostCommonTagsFromRecentPosts(100, 10).then(tags => setTags(tags)) - - // Handles any event in which appearance preferences change. - Appearance.addChangeListener(listener => { - setTheme(listener.colorScheme) - // TODO: Add return function for removing listener when user opts out of automatic theme changes. - }) - - // This listener is fired whenever a notification is received while the app is foregrounded. - notificationListener.current = Notifications.addNotificationReceivedListener(notification => { - setNotification(notification) - }) - - // This listener is fired whenever a user taps on or interacts with a notification. - responseListener.current = Notifications.addNotificationResponseReceivedListener(response => { - // Works when app is foregrounded, backgrounded or killed. - Model.posts().id(response.notification.request.trigger.payload.body.postID).embed().then(result => { - navigate(Strings.post, { item: result }) - }) - }) - - // FIXME: Listener for when app is opened from web browser. - Linking.addEventListener("url", response => { - if (response.url) { - const url = response.url - const slug = url.split("/").pop() - if (slug?.length > 0) { - Model.posts().slug(slug).embed().then(result => { - navigate(Strings.post, { item: result }) - }) - } - } - }) - - return () => { - Notifications.removeNotificationSubscription(notificationListener.current) - Notifications.removeNotificationSubscription(responseListener.current) - } - }, []) - - - return fontsLoaded && ( - - - - - - - - - - - - - - - - - ) -} \ No newline at end of file diff --git a/App.jsx b/App.jsx new file mode 100644 index 00000000..9a48b685 --- /dev/null +++ b/App.jsx @@ -0,0 +1,267 @@ +import { TECH_PASSWORD, FIREBASE_PASSWORD } from "@env"; +import * as eva from "@eva-design/eva"; +import { DarkTheme, DefaultTheme, NavigationContainer } from "@react-navigation/native"; +import { createStackNavigator } from "@react-navigation/stack"; +import { ApplicationProvider, Icon, IconRegistry, Text } from "@ui-kitten/components"; +import { EvaIconsPack } from "@ui-kitten/eva-icons"; +import * as Device from "expo-device"; +import * as Font from "expo-font"; +import * as Notifications from "expo-notifications"; +import { getAuth, signInWithEmailAndPassword } from "firebase/auth"; +import { ref, runTransaction } from "firebase/database"; +import { decode } from "html-entities"; +import React, { useEffect, useRef, useState } from "react"; +import { Appearance, Image, Linking, TouchableOpacity } from "react-native"; +import { SafeAreaProvider } from "react-native-safe-area-context"; + +import { Author, Home, Post, Search, Section } from "./components/screens"; +import { useFirebase } from "./hooks/useFirebase"; +import mapping from "./mapping.json"; +import { logoAssets, navigate } from "./navigation"; +import { DailyBread as bread } from "./theme"; +import { ThemeContext } from "./theme-context"; +import { enableAnimationExperimental, onShare, registerForPushNotificationsAsync } from "./utils/action"; +import { Fonts, Routing } from "./utils/constants"; +import { getMostCommonTagsFromRecentPosts } from "./utils/format"; +import Model from "./utils/model"; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), +}); + +const Stack = createStackNavigator(); +enableAnimationExperimental(); + +export default function App() { + const [fontsLoaded, setFontsLoaded] = useState(false); + const [expoPushToken, setExpoPushToken] = useState(""); + const [notification, setNotification] = useState(false); + const notificationListener = useRef(); + const responseListener = useRef(); + const colorScheme = Appearance.getColorScheme(); + const [theme, setTheme] = useState(colorScheme); + const [deviceType, setDeviceType] = useState(Device.DeviceType.PHONE); + const [tags, setTags] = useState([]); + const [sessionViews, setSessionViews] = useState({}); + + const toggleTheme = () => { + const next = theme === "light" ? "dark" : "light"; + setTheme(next); + }; + + const navigatorTheme = { + light: DefaultTheme, + dark: DarkTheme, + }; + + const headerOptions = ({ navigation }) => ({ + headerTitle: () => ( + Stanford Daily wordmark logo + ), + headerRight: () => ( + navigation.navigate(Routing.search, { tags })}> + + + ), + }); + + const detailHeaderOptions = ({ route }) => ({ + headerTitle: "", + headerTransparent: true, + headerTintColor: "white", + headerBackTitleVisible: false, + headerRight: () => ( + onShare(route.params.article.link, decode(route.params.article.title.rendered))} + > + + + ), + }); + + const sectionOptions = ({ route }) => ({ + headerTitle: () => {decode(route.params.category.name).replace("'", "\u{2019}")}, + headerTitleStyle: { fontFamily: "MinionProBold" }, + headerTintColor: bread[theme]["color-primary-500"], + }); + + const authorOptions = ({ route }) => ({ + headerTitle: () => {route.params.name}, + headerTitleStyle: { fontFamily: "MinionProBold" }, + headerTintColor: bread[theme]["color-primary-500"], + }); + + const searchHeaderOptions = { + headerTintColor: bread[theme]["color-primary-500"], + }; + + // const { app, database } = useFirebase(expoPushToken, FIREBASE_PASSWORD); + + // Handles changes in the navigation state (as received from `NavigationContainer`) and logs them to a Firebase database. + const handleNavigationChange = async (state) => { + /* + if (!app || !state || !state.routes) return; + const auth = getAuth(app); + + try { + await signInWithEmailAndPassword(auth, "tech@stanforddaily.com", TECH_PASSWORD); + + const currentRoute = state.routes[state.index]; + const currentView = currentRoute?.name; + const currentRouteParams = currentRoute?.params; + + if (!currentView) return; + + const datetime = new Date(); + const year = datetime.getFullYear(); + const month = String(datetime.getMonth() + 1).padStart(2, "0"); + const day = String(datetime.getDate()).padStart(2, "0"); + + let currentViewPath = `Analytics/${year}/${month}/${day}/${currentView}`; + let viewIdentifier; // Used to track unique views for each screen in the sessions map. + let routeParamIdentifier; + + // Leverage information about the current view to construct a unique identifier for use in Firebase. + switch (currentView) { + case Routing.post: + routeParamIdentifier = currentRouteParams?.article?.id; // Unique to the post being presented in the detail view. + break; + case Routing.section: + routeParamIdentifier = currentRouteParams?.category?.id; // Unique to the section being presented in the detail view. + break; + case Routing.author: + routeParamIdentifier = currentRouteParams?.id; // Unique to the author being presented in the detail view. + break; + default: + viewIdentifier = currentView; + } + + if (routeParamIdentifier) { + // After the switch, if there is a `routeParamIdentifier`, it appends that to the `currentViewPath` string. + currentViewPath += `/${routeParamIdentifier}`; + viewIdentifier = `${currentView}/${routeParamIdentifier}`; + } else if (!viewIdentifier) { + // Otherwise, the default case must have been triggered, so it just uses the view identifier. + viewIdentifier = currentView; + } + + const impressionsRef = ref(database, `${currentViewPath}/impressions`); + runTransaction(impressionsRef, (impressions) => { + return (impressions || 0) + 1; + }); + + if (!sessionViews[viewIdentifier]) { + const sessionsRef = ref(database, `${currentViewPath}/sessions`); + runTransaction(sessionsRef, (sessions) => { + return (sessions || 0) + 1; + }); + + // Update view information for future reference. + setSessionViews((prevSessionViews) => { + return { ...prevSessionViews, [viewIdentifier]: true }; + }); + } + } catch (error) { + console.trace(error); + } + */ + }; + + // Triggered when the app is opened from a web browser. It extracts the slug from the URL and navigates to the corresponding post. + const handleOpenURL = async (event) => { + // FIXME: Listener for when app is opened from web browser. + const slug = event?.url?.split("/")?.pop(); + if (slug?.length > 0) { + const result = await Model.posts().embed().slug(slug).embed(); + navigate(Routing.post, { item: result }); + } + }; + + useEffect(() => { + // Loads fonts from static resource. + Font.loadAsync(Fonts.minion).then(() => setFontsLoaded(true)); + + registerForPushNotificationsAsync().then((token) => { + const matches = token?.match(/\[(.*?)\]/); + if (matches) { + const submatch = matches[1]; + setExpoPushToken(submatch); + } + }); + + Device.getDeviceTypeAsync().then((type) => setDeviceType(type)); + + getMostCommonTagsFromRecentPosts(100, 10).then((tags) => setTags(tags)); + + // Handles any event in which appearance preferences change. + const themeListener = Appearance.addChangeListener((listener) => { + if (theme !== listener.colorScheme) { + setTheme(listener.colorScheme); + } + }); + + // This listener is fired whenever a notification is received while the app is foregrounded. + notificationListener.current = Notifications.addNotificationReceivedListener((notification) => { + setNotification(notification); + }); + + // This listener is fired whenever a user taps on or interacts with a notification. + responseListener.current = Notifications.addNotificationResponseReceivedListener((response) => { + // Works when app is foregrounded, backgrounded or killed. + Model.posts() + .id(response.notification.request.trigger.payload.body.postID) + .embed() + .get() + .then((result) => { + navigate(Routing.post, { item: result }); + }); + }); + + // Perform initial URL check in case the app was closed when the user opened a URL. + Linking.getInitialURL().then((url) => { + if (url) { + handleOpenURL({ url }); + } + }); + + Linking.addEventListener("url", handleOpenURL); + + return () => { + themeListener.remove(); + Notifications.removeNotificationSubscription(notificationListener.current); + Notifications.removeNotificationSubscription(responseListener.current); + // Linking.removeEventListener("url", handleOpenURL); Seemed to be causing crashes. + }; + }, [theme]); + + return ( + fontsLoaded && ( + + + + + + + + + + + + + + + + + ) + ); +} diff --git a/app.json b/app.json index 8b81c858..fa1ad982 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "description": "The Stanford Daily Mobile App", "slug": "StanfordDaily", "privacy": "public", - "version": "2.3.1", + "version": "2.3.2", "platforms": [ "ios", "android" @@ -28,14 +28,14 @@ ], "ios": { "bundleIdentifier": "com.Stanford.Daily.App", - "buildNumber": "28", + "buildNumber": "29", "appStoreUrl": "https://itunes.apple.com/app/stanford-daily/id1341270063", "supportsTablet": true }, "android": { "package": "com.Stanford.Daily.App", "googleServicesFile": "./google-services.json", - "versionCode": 28, + "versionCode": 29, "playStoreUrl": "https://play.google.com/store/apps/details?id=com.Stanford.Daily.App", "permissions": [ "ACCESS_COARSE_LOCATION", @@ -46,7 +46,17 @@ "icon": "./assets/images/icon.png" }, "plugins": [ - "expo-build-properties" + ["expo-build-properties", + { + "android": { + "compileSdkVersion": 31, + "targetSdkVersion": 31, + "buildToolsVersion": "31.0.0" + }, + "ios": { + "deploymentTarget": "13.0" + } + }] ], "extra": { "eas": { diff --git a/components/README.md b/components/README.md new file mode 100644 index 00000000..f8e5059e --- /dev/null +++ b/components/README.md @@ -0,0 +1,104 @@ +# Components + +## Carousel +The `Carousel` component displays a horizontal scrollable list of articles, chiefly featured ones. +Its content panels are `Card` components that display a large image and details like title, date and image below. + +**Component**: +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| navigation | Object | The navigation object from React Navigation. | +| articles | Array | An array of articles to be displayed in the carousel. | + +**Example** +```js + +``` + +## Mark +`Mark` is large section signpost in all caps. + +This component displays the name of a section and provides navigation to a screen with more articles from that section. +The `TouchableOpacity` makes the entire component tappable and changes its opacity as an indication of impression. + +The component's background color and text color may change depending on the `alternate` prop. +If set to `true`, the background takes on the accent color typically used for the Humor section on the website. + +**Component**: +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| navigation | Object | Passed from the `Home` screen, which automatically receives the navigation object from React Navigation. | +| alternate | boolean | A flag indicating whether to use an accent color. Primarily intended for displaying Humor section. | +| seed | Array | A collection of articles from the `Home` screen that can be immediately displayed upon navigation to the detail view. | +| category | Object | The category object representing the section. It should have a `name` property like "News" or "The Grind." | + +## Polyptych +Displays a set of articles in a paginated layout. + +`Polyptych` groups the articles into pages and displays each page as a row of cards. +Each card represents an article and includes an image, the article's title, and the date it was published. +The image is displayed at the top of the card, the title is displayed in the middle, and the date is displayed at the bottom. + +The number of cards in each row depends on the device type. +If the device is a phone, there are two cards per row. +If the device is a tablet or desktop, there are three cards per row. + +The user can navigate between pages by swiping left or right. +The currently selected page is stored in the `selection` state variable but is not used for anything yet in particular. +When a card is tapped, the component navigates to the `Post` screen and passes the corresponding article as a parameter. + +Canonically, a polyptych is an altarpiece of multiple panels that are joined by hinges. The name is a metaphor. + +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| articles | Array.<Object> | The array of article objects to display. Expected to be non-null. | +| navigation | Object | The navigation object used for navigating between screens. | + +**Example** +```js + +``` + +## Wildcard +Displays a `Card` preview for an article or some other related form of content. + +**Component**: +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| navigation | Object | The navigation object used for navigating between screens. | +| verbose | boolean | A flag indicating whether to include the excerpt in the card. | +| title | string | The headline. | +| item | Object | The article object. | +| index | number | The index of the article in a parent view. Currently unused in the implementation. | + +## Header +`Header` is a sub-component of `Wildcard` that displays the article title, date, and image. + +**Component**: +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| title | string | The title of the article. | +| verbose | boolean | A flag indicating whether to include the excerpt in the card. | +| date | string | The publication date. | +| uri | string | The URI for the feature image of the article. | + +## Footer +`Footer` is a sub-component of `Wildcard` that displays the article byline and section. + +**Component**: +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| byline | string | The author's name(s) in a displayable format. | +| section | string | The name of the section to which the article belongs. | diff --git a/components/common/Byline.js b/components/common/Byline.js deleted file mode 100644 index 725d80f6..00000000 --- a/components/common/Byline.js +++ /dev/null @@ -1,47 +0,0 @@ -import React, { useEffect, useState } from "react" -import { TouchableOpacity, StyleSheet, View } from "react-native" -import { Button, Text, Icon, useTheme } from "@ui-kitten/components" -import { Spacing } from "../../utils/constants" -import { decode } from "html-entities" - -export default function Byline({ authors, section, sourceName, category, date, navigation }) { - const entries = Object.entries(authors) - const bylineFontSize = entries.length < 3 ? 16 : 14 - const [sameCategory, setSameCategory] = useState(false) - const theme = useTheme() - - - const Name = ({ detail }) => ( - navigation.navigate("Author", { name: detail[0], id: detail[1] })}> - {detail[0]} - - ) - - return ( - - - - {entries[0][1] != 16735 && (By )} - {entries.map((entry, index) => ).reduce((p, q, index) => [p, {index < entries.length - 1 ? ", " : " and "}, q])} - - {date} - - - - - - ) -} - -const styles = StyleSheet.create({ - byline: { - flexDirection: "row", - paddingTop: Spacing.medium, - justifyContent: "space-between", - alignItems: "center" - } -}) \ No newline at end of file diff --git a/components/common/Byline.jsx b/components/common/Byline.jsx new file mode 100644 index 00000000..5f328685 --- /dev/null +++ b/components/common/Byline.jsx @@ -0,0 +1,73 @@ +import { Button, Text } from "@ui-kitten/components"; +import { decode } from "html-entities"; +import React from "react"; +import { TouchableOpacity, StyleSheet, View } from "react-native"; + +import { Spacing, Sections } from "../../utils/constants"; + +const includesDesks = (category) => { + return category.desks && Object.keys(category.desks).length > 0; +}; + +export default function Byline({ authors, section, sourceName, category, date, navigation }) { + const entries = Object.entries(authors); + const bylineFontSize = entries.length < 3 ? 16 : 14; + const richCategory = Object.values(Sections).find((value) => value.id === category.id) ?? category; + const resolvedCategory = [3, 23, 25].includes(category.id) && includesDesks(richCategory) ? richCategory : category; + + const Name = ({ detail }) => ( + navigation.navigate("Author", { name: detail[0], id: detail[1] })}> + + {detail[0]} + + + ); + + return ( + + + + {entries[0][1] !== 16735 && ( + + By{" "} + + )} + {entries + .map((entry, index) => ) + .reduce((p, q, index) => [ + p, + + {index < entries.length - 1 ? ", " : " and "} + , + q, + ])} + + {date} + + + + + ); +} + +const styles = StyleSheet.create({ + byline: { + flexDirection: "row", + paddingTop: Spacing.medium, + justifyContent: "space-between", + alignItems: "center", + }, +}); diff --git a/components/common/CachedImage.js b/components/common/CachedImage.jsx similarity index 100% rename from components/common/CachedImage.js rename to components/common/CachedImage.jsx diff --git a/components/common/Canvas.js b/components/common/Canvas.jsx similarity index 65% rename from components/common/Canvas.js rename to components/common/Canvas.jsx index 825b35af..4063da6a 100644 --- a/components/common/Canvas.js +++ b/components/common/Canvas.jsx @@ -1,20 +1,20 @@ -import React from "react" -import { Dimensions, Image, PixelRatio, StyleSheet, Text, View } from "react-native" +import React from "react"; +import { Dimensions, Image, PixelRatio, StyleSheet, Text, View } from "react-native"; // import Carousel from "react-native-snap-carousel" -const { width, height } = Dimensions.get("window") -const pixelRatio = PixelRatio.get() +const { width, height } = Dimensions.get("window"); +const pixelRatio = PixelRatio.get(); export default function Canvas({ navigation, articles }) { - return ( - - {/* + {/* } sliderWidth={width} itemWidth={width} layout="tinder" /> */} - - ) + + ); } diff --git a/components/common/Carousel.js b/components/common/Carousel.js deleted file mode 100644 index 68d2400f..00000000 --- a/components/common/Carousel.js +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useContext, useState } from "react" -import { Card, Button, Layout, Text, useTheme } from "@ui-kitten/components" -import { Dimensions, View, StyleSheet, PixelRatio, Platform } from "react-native" -import { Image } from "expo-image" -import PagerView from "react-native-pager-view" -import moment from "moment" -import _ from "lodash" -import { decode } from "html-entities" -import { itemize } from "../../utils/format" -import { Spacing } from "../../utils/constants" -import { ThemeContext } from "../../theme-context" -import { DeviceType } from "expo-device" - -const { width } = Dimensions.get("window") -const pixelRatio = PixelRatio.get() - -function Carousel(props) { - const { navigation, articles } = props - const { theme, deviceType } = useContext(ThemeContext) - const carouselHeight = deviceType === DeviceType.PHONE ? 300 : 350 - const accentColor = useTheme()["color-primary-600"] - - // Seems like the card suddenly stopped rounding off the top corners of the image automatically. - // Might have to do with the dynamic styling of the border below. - const Header = ({ source }) => { - return ( - - ) - } - - const Footer = (props) => ( - - {itemize(Object.keys(props.authors).map(name => name.toUpperCase()))} - - - ) - - return ( - - {articles?.map((item, index) => ( - - } - footer={