diff --git a/app.json b/app.json index 7abf5f5..c77fdb9 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,10 @@ "slug": "bacon", "scheme": "bacon", "privacy": "public", - "platforms": ["ios", "web"], + "platforms": [ + "ios", + "web" + ], "version": "1.0.0", "orientation": "default", "userInterfaceStyle": "dark", @@ -19,7 +22,9 @@ "updates": { "fallbackToCacheTimeout": 0 }, - "assetBundlePatterns": ["**/*"], + "assetBundlePatterns": [ + "**/*" + ], "web": { "favicon": "./assets/favicon.png", "bundler": "metro", @@ -33,14 +38,21 @@ "typedRoutes": true }, "ios": { + "appleTeamId": "QQ57RJ5UTD", "bundleIdentifier": "evanbacon.dev", "supportsTablet": false, + "entitlements": { + "com.apple.security.application-groups": [ + "group.bacon.data" + ] + }, "infoPlist": { - "UIViewControllerBasedStatusBarAppearance": true, + "UIViewControllerBasedStatusBarAppearance": false, "ITSAppUsesNonExemptEncryption": false } }, "plugins": [ + "@bacons/apple-targets", [ "expo-router", { @@ -49,7 +61,8 @@ "asyncRoutes": true } ], - "expo-quick-actions" + "expo-quick-actions", + "expo-video" ], "extra": { "eas": { diff --git a/assets/widget-icon.png b/assets/widget-icon.png new file mode 100644 index 0000000..66f5254 Binary files /dev/null and b/assets/widget-icon.png differ diff --git a/bun.lockb b/bun.lockb index 7eaadab..6dde1ea 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 8ee344b..3f9356a 100644 --- a/package.json +++ b/package.json @@ -10,44 +10,45 @@ }, "dependencies": { "@bacons/apple-colors": "^0.0.6", + "@bacons/apple-targets": "^0.1.14", "@bacons/mdx": "^0.3.7", "@expo-google-fonts/inter": "^0.2.3", "@expo-google-fonts/source-code-pro": "^0.2.3", - "@expo/html-elements": "^0.10.1", - "@expo/react-native-action-sheet": "^3.12.0", + "@expo/html-elements": "^0.11.0", + "@expo/react-native-action-sheet": "^4.1.0", "@expo/styleguide-icons": "^1.0.1", "@tanstack/react-query": "^5.8.7", "autoprefixer": "10.4.14", "classnames": "^2.3.2", - "expo": "52.0.0-preview.10", + "expo": "^52", "expo-av": "~15.0.1", "expo-blur": "~14.0.1", "expo-haptics": "~14.0.0", - "expo-linking": "~7.0.2", - "expo-quick-actions": "^2.0.0", - "expo-router": "4.0.0-preview.6", + "expo-linking": "~7.0.3", + "expo-quick-actions": "^3.0.0", + "expo-router": "~4.0.11", "expo-sf-symbols": "^1.0.20", - "expo-splash-screen": "~0.28.5", + "expo-splash-screen": "~0.29.16", "expo-status-bar": "~2.0.0", "expo-symbols": "~0.2.0", - "expo-system-ui": "~4.0.2", - "expo-video": "~2.0.0-preview.0", - "expo-web-browser": "~14.0.0", + "expo-system-ui": "~4.0.5", + "expo-video": "~2.0.2", + "expo-web-browser": "~14.0.1", "postcss-scss": "^4.0.6", "prism-react-renderer": "^2.0.6", "prismjs": "^1.29.0", "react": "18.3.1", "react-dom": "18.3.1", - "react-native": "0.76.1", + "react-native": "0.76.3", "react-native-gesture-handler": "~2.20.2", "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", - "react-native-screens": "4.0.0-beta.16", + "react-native-screens": "~4.3.0", "react-native-svg": "15.8.0", "react-native-svg-transformer": "^1.0.0", "react-native-web": "~0.19.13", "react-native-web-hooks": "^3.0.2", - "react-native-webview": "13.12.2", + "react-native-webview": "13.12.5", "sass": "^1.63.4", "satori": "0.10.4", "tailwindcss": "3.3.2", diff --git a/public/blog/api-routes-rfc.jpg b/public/blog/api-routes-rfc.jpg new file mode 100644 index 0000000..6b76810 Binary files /dev/null and b/public/blog/api-routes-rfc.jpg differ diff --git a/public/blog/apple-home-screen-widgets.jpg b/public/blog/apple-home-screen-widgets.jpg new file mode 100644 index 0000000..64ebde3 Binary files /dev/null and b/public/blog/apple-home-screen-widgets.jpg differ diff --git a/public/blog/apple-settings.jpg b/public/blog/apple-settings.jpg new file mode 100644 index 0000000..c8ebc2f Binary files /dev/null and b/public/blog/apple-settings.jpg differ diff --git a/public/blog/expo-2024.jpg b/public/blog/expo-2024.jpg new file mode 100644 index 0000000..e2d44ad Binary files /dev/null and b/public/blog/expo-2024.jpg differ diff --git a/public/blog/universal-links.jpg b/public/blog/universal-links.jpg new file mode 100644 index 0000000..2f87339 Binary files /dev/null and b/public/blog/universal-links.jpg differ diff --git a/src/app/(index,blog,games)/blog/index.tsx b/src/app/(index,blog,games)/blog/index.tsx index 2ed5c6c..aadacaa 100644 --- a/src/app/(index,blog,games)/blog/index.tsx +++ b/src/app/(index,blog,games)/blog/index.tsx @@ -21,7 +21,7 @@ const posts = mdxctx .map(key => mdxctx(key)); const POSTS = posts - .map(({ title, shortTitle, subtitle, date, slug }) => ({ + .map(({ title, shortTitle, subtitle, date, slug, featuredImage }) => ({ title: shortTitle ?? title, description: subtitle, value: new Date(date).toLocaleDateString('en-US', { @@ -31,11 +31,70 @@ const POSTS = posts }), date, href: `/blog/${slug}`, + slug, + img: featuredImage, })) .sort((a, b) => b.date.localeCompare(a.date)); +import { ExtensionStorage } from '@bacons/apple-targets'; +import { useEffect } from 'react'; +import * as Linking from 'expo-linking'; + +const extStorage = new ExtensionStorage('group.bacon.data'); + +function updateWidgetData( + data: { + imageUrl: string; + title: string; + date: string; + href: string; + }[] +) { + extStorage.set('articlesData', data); + ExtensionStorage.reloadWidget(); +} + +function sortRandomly(arr: T[]) { + return arr.sort(() => Math.random() - 0.5); +} + +function useLatestPostsInWidget() { + useEffect(() => { + if (process.env.EXPO_OS === 'ios') { + (async () => { + try { + const posts = await Promise.all( + sortRandomly(POSTS.slice(0, 6)).map( + async ({ title, img, date, href, slug }) => ({ + title, + date: new Date(date).toISOString(), + imageUrl: new URL( + '/blog/' + slug + '.jpg', + window.location.href + ).toString(), + // imageUrl: !img + // ? 'https://github.com/evanbacon.png' + // : (await Asset.fromModule(img).downloadAsync()).localUri, + // imageUrl: 'https://github.com/evanbacon.png', + href: Linking.createURL(href.replace(/^\//, '')), + }) + ) + ); + + updateWidgetData(posts); + } catch (error) { + console.error('error', error); + } + })(); + } + }, []); +} + export default function App() { + useLatestPostsInWidget(); + const paddingBottom = useBottomTabOverflow(); + if (process.env.EXPO_OS === 'web') { return (
diff --git a/src/components/front/masonry.tsx b/src/components/front/masonry.tsx index c285d16..356dffc 100644 --- a/src/components/front/masonry.tsx +++ b/src/components/front/masonry.tsx @@ -2,7 +2,11 @@ import cn from 'classnames'; import { IS_DOM } from 'expo/dom'; import { Image } from 'react-native'; -const baseUrl = IS_DOM ? process.env.EXPO_DOM_BASE_URL : ''; +const baseUrl = IS_DOM + ? __DEV__ + ? new URL('/', window.location.href).toString() + : process.env.EXPO_BASE_URL + : ''; const legoImages: [string, string][] = [ ['/front/lego/stanlee.avif', 'Stan Lee and Evan Bacon in Austin, 2013'], diff --git a/src/components/showcase/apps-list.tsx b/src/components/showcase/apps-list.tsx index f5598f9..8ca23f8 100644 --- a/src/components/showcase/apps-list.tsx +++ b/src/components/showcase/apps-list.tsx @@ -110,7 +110,11 @@ export function ShowcaseData({ ); } -const baseUrl = IS_DOM ? process.env.EXPO_DOM_BASE_URL : ''; +const baseUrl = IS_DOM + ? __DEV__ + ? new URL('/', window.location.href).toString() + : process.env.EXPO_BASE_URL + : ''; function ShowcaseCategoryRow({ category, diff --git a/src/components/universal-links/LinksChart.tsx b/src/components/universal-links/LinksChart.tsx index 5022426..0b984ba 100644 --- a/src/components/universal-links/LinksChart.tsx +++ b/src/components/universal-links/LinksChart.tsx @@ -55,7 +55,11 @@ export default function UniversalLinksVisualized({ }: { endpoint: string; }) { - const baseUrl = IS_DOM ? process.env.EXPO_DOM_BASE_URL : ''; + const baseUrl = IS_DOM + ? __DEV__ + ? new URL('/', window.location.href).toString() + : process.env.EXPO_BASE_URL + : ''; const modifiedEndpoint = baseUrl + endpoint; diff --git a/targets/widget/Assets.xcassets/logo.imageset/Contents.json b/targets/widget/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000..7aac5fa --- /dev/null +++ b/targets/widget/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "widget-icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/targets/widget/Assets.xcassets/logo.imageset/widget-icon.png b/targets/widget/Assets.xcassets/logo.imageset/widget-icon.png new file mode 100644 index 0000000..66f5254 Binary files /dev/null and b/targets/widget/Assets.xcassets/logo.imageset/widget-icon.png differ diff --git a/targets/widget/Info.plist b/targets/widget/Info.plist new file mode 100644 index 0000000..5510804 --- /dev/null +++ b/targets/widget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + \ No newline at end of file diff --git a/targets/widget/Widget.swift b/targets/widget/Widget.swift new file mode 100644 index 0000000..76c1cf1 --- /dev/null +++ b/targets/widget/Widget.swift @@ -0,0 +1,304 @@ +import WidgetKit +import SwiftUI + +struct ArticleData: Identifiable, Codable { + let id = UUID() + let imageUrl: String + let title: String + let date: String + let href: String + + enum CodingKeys: String, CodingKey { + case imageUrl + case title + case date + case href + } +} + +struct WidgetData: Codable { + var articles: [ArticleData] +} + +struct ArticleView: View { + let article: ArticleData + + let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + var body: some View { + Link(destination: URL(string: article.href)!) { + HStack { + if let url = URL(string: article.imageUrl), let imageData = try? Data(contentsOf: url), let uiImage = UIImage(data: imageData) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 50, height: 50) + .clipped() + .clipShape(.rect(cornerSize: .init(width: 12, height: 12))) + } else { + Color.gray + .frame(width: 50, height: 50) + } + VStack(alignment: .leading) { + Text(article.title) + .font(.headline) + .lineLimit(2) + + if let date = isoFormatter.date(from: article.date) { + Text(date, style: .date) + .font(.caption) + .foregroundColor(Color(.secondaryLabel)) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct NewsWidgetEntryView: View { + @Environment(\.widgetFamily) var family + var entry: NewsProvider.Entry + + var body: some View { + Group { + if family == .systemMedium { + mediumView + } else if family == .systemSmall { + smallView + } else { + largeView + } + } + .containerBackground(for: .widget) { + if family == .systemSmall { + // Full-size background image + if let article = entry.data.articles.first, let url = URL(string: article.imageUrl), + let imageData = try? Data(contentsOf: url), + let uiImage = UIImage(data: imageData) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } else { + Color(.systemBackground) + } + } else { + Color(.systemBackground) + } + } + } + + let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + + @ViewBuilder + private var smallView: some View { + if let article = entry.data.articles.first { + ZStack { + + // Text overlay + VStack(alignment: .leading) { + Spacer() + Text(article.title) + .font(.headline) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .shadow(color: .black.opacity(0.7), radius: 2, x: 0, y: 1) + .padding(0) + if let date = isoFormatter.date(from: article.date) { + Text(date, style: .date) + .font(.caption) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .shadow(color: .black.opacity(0.7), radius: 2, x: 0, y: 1) + .foregroundColor(Color(.secondaryLabel)) + + } + } + + // Logo overlay + VStack { + HStack { + Spacer() + Image(.logo) + .renderingMode(.template) + .resizable() + .frame(width: 18, height: 18) + .padding(4) + .background(.white.opacity(0.5)) + .clipShape(.circle) + .foregroundColor(.black) + } + Spacer() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .widgetURL(URL(string: article.href)!) + // Ensure ZStack fills the container + } + } + + @ViewBuilder + private var mediumView: some View { + ZStack { + + VStack(alignment: .leading, spacing: 8) { + ForEach(entry.data.articles.prefix(2)) { article in + ArticleView(article: article) + } + } + VStack{ + HStack { + Spacer() + Image(.logo) + .renderingMode(.template) + .resizable() + .frame(width: 18, height: 18) + .padding(4) + .background(.white.opacity(0.5)) + .clipShape(.circle) + .foregroundColor(.black) + + } + Spacer() + } + + } + + } + + @ViewBuilder + private var largeView: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(entry.data.articles.prefix(6)) { article in + ArticleView(article: article) + } + } + + } +} + +struct NewsProvider: TimelineProvider { + func placeholder(in context: Context) -> NewsEntry { + NewsEntry(date: Date(), data: defaultData()) + } + + func getSnapshot(in context: Context, completion: @escaping (NewsEntry) -> ()) { + let entry = NewsEntry(date: Date(), data: loadDataFromSharedStore()) + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + let data = loadDataFromSharedStore() + let entry = NewsEntry(date: Date(), data: data) + + let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())! + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + + completion(timeline) + } + + func loadDataFromSharedStore() -> WidgetData { + let sharedDefaults = UserDefaults(suiteName: "group.bacon.data") + + var articlesArray: [ArticleData] = sampleArticles() + if let articlesData = sharedDefaults?.data(forKey: "articlesData") { + let decoder = JSONDecoder() + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = isoFormatter.date(from: dateString) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format: \(dateString)") + } + + do { + let decodedArticles = try decoder.decode([ArticleData].self, from: articlesData) + articlesArray = decodedArticles + } catch { + print("Failed to decode articles data: \(error)") + if let jsonString = String(data: articlesData, encoding: .utf8) { + print("Raw articlesData JSON: \(jsonString)") + } + } + } + + return WidgetData(articles: articlesArray) + } + + func defaultData() -> WidgetData { + WidgetData(articles: sampleArticles()) + } + + func sampleArticles() -> [ArticleData] { + return [ + ArticleData(imageUrl: "https://github.com/evanbacon.png", title: "Sample Article 1", date: "2024-12-10T05:18:58.102Z", href: "/blog"), + ArticleData(imageUrl: "https://github.com/expo.png", title: "Sample Article 2", date: "2023-10-02", href: "/blog"), + ArticleData(imageUrl: "https://github.com/evanbacon.png", title: "Sample Article 3", date: "2023-10-03", href: "/blog"), + ArticleData(imageUrl: "https://github.com/expo.png", title: "Sample Article 4", date: "2023-10-04", href: "/blog") + ] + } +} + +struct NewsEntry: TimelineEntry { + let date: Date + let data: WidgetData +} + +@main +struct NewsWidget: Widget { + let kind: String = "widget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: NewsProvider()) { entry in + NewsWidgetEntryView(entry: entry) + } + .supportedFamilies([.systemSmall, .systemMedium]) + .configurationDisplayName("Bacon Blog") + .description("View the latest articles.") + } +} + +#if DEBUG +struct NewsWidgetEntryView_Previews: PreviewProvider { + static var sampleData: WidgetData { + WidgetData(articles: [ + ArticleData(imageUrl: "https://github.com/evanbacon.png", title: "Sample Article 1", date: "2024-12-10T05:18:58.102Z", href: "/blog"), + ArticleData(imageUrl: "https://github.com/expo.png", title: "Sample Article 2", date: "2023-10-02", href: "/blog"), + ArticleData(imageUrl: "https://github.com/evanbacon.png", title: "Sample Article 3", date: "2023-10-03", href: "/blog"), + ArticleData(imageUrl: "https://github.com/expo.png", title: "Sample Article 4", date: "2023-10-04", href: "/blog") + ]) + } + + static var entry: NewsEntry { + NewsEntry(date: Date(), data: sampleData) + } + + static var previews: some View { + Group { + NewsWidgetEntryView(entry: entry) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + + NewsWidgetEntryView(entry: entry) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + + // NewsWidgetEntryView(entry: entry) + // .previewContext(WidgetPreviewContext(family: .systemLarge)) + } + } +} +#endif diff --git a/targets/widget/expo-target.config.js b/targets/widget/expo-target.config.js new file mode 100644 index 0000000..c24aecb --- /dev/null +++ b/targets/widget/expo-target.config.js @@ -0,0 +1,7 @@ +/** @type {import('@bacons/apple-targets/app.plugin').Config} */ +module.exports = { + type: 'widget', + images: { + logo: '../../assets/widget-icon.png', + }, +}; diff --git a/targets/widget/generated.entitlements b/targets/widget/generated.entitlements new file mode 100644 index 0000000..d1b5ffa --- /dev/null +++ b/targets/widget/generated.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.bacon.data + + + \ No newline at end of file