diff --git a/app/components/TheHome.server.vue b/app/components/TheHome.server.vue
index e93056f8..e6530416 100644
--- a/app/components/TheHome.server.vue
+++ b/app/components/TheHome.server.vue
@@ -22,29 +22,63 @@ const links = [
},
]
-const { data: upcomingConferences } = await useFetch('/api/upcoming-conferences')
+const [{ data: currentLocation }, { data: upcomingConferences }, { data: streams }, { data: articles }, { data: talks }] = await Promise.all([
+ useFetch('/api/current-location'),
+ useFetch('/api/upcoming-conferences'),
+ useFetch('/api/streams', {
+ transform: streams =>
+ streams.slice(0, 5).map(stream => ({
+ title: stream.title,
+ thumbnail: stream.thumbnail_url
+ .replace('%{width}', '1200')
+ .replace('%{height}', '630'),
+ source: 'Twitch',
+ link: stream.url,
+ date: stream.created_at,
+ type: 'video',
+ video: stream.url,
+ })),
+ }),
+ useAsyncData(async () => {
+ const result = await queryCollection('blog').select('title', 'date', 'path').all()
+ return result
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
+ .slice(0, 4)
+ }),
+ useAsyncData(
+ () =>
+ ((import.meta.server || import.meta.dev) as true)
+ && import('../data/talks.json').then(r => r.default as any as Talk[]),
+ {
+ transform: talks => {
+ const groupedTalks: Record = {}
+ for (const talk of talks) {
+ const slug = talk.group || talk.slug
+ if (groupedTalks[slug]) {
+ groupedTalks[slug]!.push(talk)
+ }
+ else {
+ groupedTalks[slug] = [talk]
+ }
+ }
-const { data: streams } = await useFetch('/api/streams', {
- transform: streams =>
- streams.slice(0, 5).map(stream => ({
- title: stream.title,
- thumbnail: stream.thumbnail_url
- .replace('%{width}', '1200')
- .replace('%{height}', '630'),
- source: 'Twitch',
- link: stream.url,
- date: stream.created_at,
- type: 'video',
- video: stream.url,
- })),
-})
+ for (const group in groupedTalks) {
+ groupedTalks[group]!.sort(
+ (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
+ )
+ }
-const { data: articles } = await useAsyncData(async () => {
- const result = await queryCollection('blog').select('title', 'date', 'path').all()
- return result
- .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
- .slice(0, 4)
-})
+ const groups = Object.entries(groupedTalks).sort(
+ ([_s1, [a]], [_s2, [b]]) => {
+ return new Date(b.date).getTime() - new Date(a.date).getTime()
+ },
+ )
+
+ return groups.slice(0, 4).map(([_slug, [firstTalk]]) => firstTalk)
+ },
+ },
+ ),
+])
interface Talk {
slug: string
@@ -60,40 +94,6 @@ interface Talk {
repo?: string
demo?: string
}
-
-const { data: talks } = await useAsyncData(
- () =>
- ((import.meta.server || import.meta.dev) as true)
- && import('../data/talks.json').then(r => r.default as any as Talk[]),
- {
- transform: talks => {
- const groupedTalks: Record = {}
- for (const talk of talks) {
- const slug = talk.group || talk.slug
- if (groupedTalks[slug]) {
- groupedTalks[slug]!.push(talk)
- }
- else {
- groupedTalks[slug] = [talk]
- }
- }
-
- for (const group in groupedTalks) {
- groupedTalks[group]!.sort(
- (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
- )
- }
-
- const groups = Object.entries(groupedTalks).sort(
- ([_s1, [a]], [_s2, [b]]) => {
- return new Date(b.date).getTime() - new Date(a.date).getTime()
- },
- )
-
- return groups.slice(0, 4).map(([_slug, [firstTalk]]) => firstTalk)
- },
- },
-)
@@ -421,6 +421,21 @@ const { data: talks } = await useAsyncData(
book a meeting
.
+
+
+
+ Drop me a line
+
+ if you'd like to meet up in person! I'm planning to be in {{ currentLocation.city }}, {{ currentLocation.country === 'United Kingdom' ? 'the UK' : currentLocation.country === 'United States' ? 'the US' : currentLocation.country }} today.
+
+
diff --git a/app/components/layout/TheSiteHeader.vue b/app/components/layout/TheSiteHeader.vue
index f676d0c7..e8ed297e 100644
--- a/app/components/layout/TheSiteHeader.vue
+++ b/app/components/layout/TheSiteHeader.vue
@@ -7,6 +7,13 @@ const loginURL = `https://github.com/login/oauth/authorize?client_id=${config.pu
const showMenu = ref(false)
+const { data: currentLocation } = await useFetch('/api/current-location', {
+ transform: location => ({
+ country: location.country,
+ flagEmoji: location.flagEmoji,
+ }),
+})
+
const menu = [
{
name: 'Home',
@@ -66,6 +73,15 @@ function toggleMenu (input?: Event | boolean) {
to="/"
>
Daniel Roe
+
+
+ is currently in {{currentLocation.country === 'United Kingdom' ? 'the UK' : currentLocation.country === 'United States' ? 'the US' : currentLocation.country}}
+
+ {{ currentLocation.flagEmoji }}
+
{
status: ref('pending'),
})
- nuxtApp.hook('app:mounted', () => {
+ nuxtApp.hook('app:suspense:resolve', () => {
if (useCookie('token').value) auth.login()
else auth.status = 'logged-out'
})
diff --git a/budget.json b/budget.json
index 307b016d..17b90eeb 100644
--- a/budget.json
+++ b/budget.json
@@ -12,7 +12,7 @@
},
{
"resourceType": "stylesheet",
- "budget": 13.1
+ "budget": 13.2
}
],
"resourceCounts": [
diff --git a/cms/schemaTypes/index.ts b/cms/schemaTypes/index.ts
index c79f280e..3aa5a54f 100644
--- a/cms/schemaTypes/index.ts
+++ b/cms/schemaTypes/index.ts
@@ -1,4 +1,5 @@
import ama from './ama'
import event from './event'
+import location from './location'
-export const schemaTypes = [ama, event]
+export const schemaTypes = [ama, event, location]
diff --git a/cms/schemaTypes/location.ts b/cms/schemaTypes/location.ts
new file mode 100644
index 00000000..3f220312
--- /dev/null
+++ b/cms/schemaTypes/location.ts
@@ -0,0 +1,78 @@
+import { defineField, defineType } from 'sanity'
+
+export default defineType({
+ name: 'location',
+ title: 'Current Location',
+ type: 'document',
+ preview: {
+ select: {
+ city: 'city',
+ country: 'country',
+ countryCode: 'countryCode',
+ region: 'region',
+ },
+ prepare ({ city, country, countryCode, region }) {
+ const flagEmoji = region === 'Scotland'
+ ? '🏴'
+ : countryCode
+ ? String.fromCodePoint(...[...countryCode.toUpperCase()].map(char =>
+ char.charCodeAt(0) + 127397))
+ : '🌍'
+
+ return {
+ title: `${city}${country ? `, ${country}` : ''}`,
+ subtitle: 'Current Location',
+ media: flagEmoji,
+ }
+ },
+ },
+ fields: [
+ defineField({
+ name: 'city',
+ title: 'City',
+ type: 'string',
+ validation: Rule => Rule.required(),
+ }),
+ defineField({
+ name: 'region',
+ title: 'Region/State',
+ type: 'string',
+ }),
+ defineField({
+ name: 'country',
+ title: 'Country',
+ type: 'string',
+ validation: Rule => Rule.required(),
+ }),
+ defineField({
+ name: 'countryCode',
+ title: 'Country Code (for flag emoji)',
+ type: 'string',
+ description: 'Two-letter country code (e.g., "US" for United States)',
+ validation: Rule => Rule.required().length(2),
+ }),
+ defineField({
+ name: 'latitude',
+ title: 'Latitude',
+ type: 'number',
+ }),
+ defineField({
+ name: 'longitude',
+ title: 'Longitude',
+ type: 'number',
+ }),
+ defineField({
+ name: 'meetupAvailable',
+ title: 'Available for Meetups',
+ type: 'boolean',
+ description: 'Are you currently available to meet up with people in this location?',
+ initialValue: true,
+ }),
+ defineField({
+ name: 'updatedAt',
+ title: 'Last Updated',
+ type: 'datetime',
+ initialValue: new Date().toISOString(),
+ }),
+ ],
+})
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 0c4fda84..b6e0e3ff 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -118,6 +118,10 @@ export default defineNuxtConfig({
sessionPassword: '',
// emailing ideas
resendApiKey: '',
+ // Location API key for secure updates
+ locationApiKey: '',
+ // OpenCage API key for geocoding coordinates
+ openCageApiKey: '',
twitch: {
clientId: '',
clientSecret: '',
@@ -219,6 +223,11 @@ export default defineNuxtConfig({
optionsAPI: false,
},
},
+ server: {
+ allowedHosts: [
+ '5184-87-75-141-134.ngrok-free.app',
+ ],
+ },
},
typescript: {
diff --git a/server/api/current-location.get.ts b/server/api/current-location.get.ts
new file mode 100644
index 00000000..cc1a2161
--- /dev/null
+++ b/server/api/current-location.get.ts
@@ -0,0 +1,52 @@
+import { groq } from '#imports'
+
+const locationQuery = groq`
+*[_type == 'location'] | order(_updatedAt desc)[0] {
+ city,
+ region,
+ country,
+ countryCode,
+ meetupAvailable,
+ _updatedAt
+}
+`
+
+interface Location {
+ city: string
+ region?: string
+ country: string
+ countryCode: string
+ meetupAvailable: boolean
+ _updatedAt: string
+}
+
+const locationMaps: Record = {
+ 'City of Edinburgh': 'Edinburgh',
+ 'United Kingdom': 'the UK',
+ 'United States': 'the US',
+}
+
+export default defineEventHandler(async event => {
+ const sanity = useSanity(event)
+
+ const location = await sanity.client.fetch(locationQuery)
+
+ if (!location) {
+ return null
+ }
+
+ // Convert country code to flag emoji
+ const flagEmoji = location.region === 'Scotland'
+ ? '🏴'
+ : location.countryCode
+ ? String.fromCodePoint(...[...location.countryCode.toUpperCase()].map(char =>
+ char.charCodeAt(0) + 127397))
+ : '🌍'
+
+ return {
+ ...location,
+ city: locationMaps[location.city] || location.city,
+ country: location.region === 'Scotland' ? 'Scotland' : locationMaps[location.country] || location.country,
+ flagEmoji,
+ }
+})
diff --git a/server/api/update-location.post.ts b/server/api/update-location.post.ts
new file mode 100644
index 00000000..602cd9c2
--- /dev/null
+++ b/server/api/update-location.post.ts
@@ -0,0 +1,92 @@
+import { groq } from '#imports'
+
+export default defineEventHandler(async event => {
+ const { latitude, longitude, meetupAvailable, apiKey } = await readBody(event)
+
+ if (!latitude || !longitude || !apiKey) {
+ return createError({
+ statusCode: 400,
+ statusMessage: 'Missing required fields. Need latitude, longitude, and apiKey.',
+ })
+ }
+
+ const config = useRuntimeConfig()
+ if (apiKey !== config.locationApiKey) {
+ return createError({
+ statusCode: 401,
+ statusMessage: 'Unauthorized',
+ })
+ }
+
+ const response = await $fetch(`https://api.opencagedata.com/geocode/v1/json`, {
+ params: {
+ q: `${latitude},${longitude}`,
+ key: config.openCageApiKey,
+ no_annotations: 1,
+ language: 'en',
+ },
+ })
+
+ if (!response.results || response.results.length === 0) {
+ return createError({
+ statusCode: 400,
+ statusMessage: 'Could not geocode the provided coordinates',
+ })
+ }
+
+ const location = response.results[0]
+ const components = location.components
+
+ // Extract relevant information
+ const locationData = {
+ city: components.city || components.town || components.village || components.hamlet || 'Unknown',
+ region: components.state || components.province || components.county || '',
+ country: components.country || 'Unknown',
+ countryCode: components.country_code ? components.country_code.toUpperCase() : 'XX',
+ latitude,
+ longitude,
+ meetupAvailable: meetupAvailable !== undefined ? meetupAvailable : true,
+ updatedAt: new Date().toISOString(),
+ }
+
+ // Initialize Sanity client
+ const sanity = useSanity(event)
+
+ // Check if a location document exists
+ const existingLocation = await sanity.client.fetch(
+ groq`*[_type == "location"] | order(_updatedAt desc)[0]._id`,
+ )
+
+ if (existingLocation) {
+ // Update existing document
+ await sanity.client.patch(existingLocation).set(locationData).commit()
+ }
+ else {
+ // Create new document
+ await sanity.client.create({ _type: 'location', ...locationData })
+ }
+
+ return null
+})
+
+type OpenCageResponse = {
+ results: Array<{
+ components: {
+ city?: string
+ town?: string
+ village?: string
+ hamlet?: string
+ state?: string
+ province?: string
+ county?: string
+ country?: string
+ country_code?: string
+ }
+ geometry: {
+ lat: number
+ lng: number
+ }
+ }>
+ status: { code: number, message: string }
+ total_results: number
+}
diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts
index c5e553c7..18130e25 100644
--- a/test/unit/bundle.spec.ts
+++ b/test/unit/bundle.spec.ts
@@ -79,7 +79,7 @@ describe('project sizes', () => {
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect
.soft(roundToKilobytes(stats.server.totalBytes))
- .toMatchInlineSnapshot(`"420k"`)
+ .toMatchInlineSnapshot(`"424k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect