PageSourceSearch

https://shoemoney.com/home.js

js shoemoney.com collected 2026-10-01 10:44:48 UTC 16,499 bytes, 238 lines download raw bytes

1/* Vue 3 owns navigation, project filters, motion preference and the portrait gallery. */
2(() => {
3  'use strict';
4  if (!window.Vue) return; // The semantic document and links remain useful if JS is unavailable.
5  const { createApp, ref, computed, watch, nextTick, onMounted, onBeforeUnmount } = Vue;
6  const skillCatalog = [...document.querySelectorAll('.skill-card')].map(card => ({
7    id: card.dataset.skillId,
8    category: card.dataset.skillCategory,
9    search: `${card.dataset.skillId} ${card.textContent}`.toLowerCase(),
10  }));
11  const portraits = [
12    { id: 'studio', title: 'The studio', src: './assets/jeremy-studio.webp', alt: 'Full-body AI editorial portrait of Jeremy Schoemaker in a dark studio' },
13    { id: 'workspace', title: 'The workshop', src: './assets/jeremy-workspace.webp', alt: 'Full-body AI editorial portrait of Jeremy Schoemaker in a developer workspace' },
14    { id: 'speaking', title: 'On stage', src: './assets/jeremy-conference.webp', alt: 'Full-body AI editorial portrait of Jeremy Schoemaker with a microphone on a stage' },
15    { id: 'city', title: 'The city', src: './assets/jeremy-city.webp', alt: 'Full-body AI editorial portrait of Jeremy Schoemaker in the city' },
16  ];
17  const app = createApp({
18    setup() {
19      const media = window.matchMedia('(prefers-reduced-motion: reduce)');
20      const menuOpen = ref(false), activeSection = ref('home'), scrollProgress = ref(0);
21      const motionEnabled = ref(!media.matches), filter = ref('all'), filterStatus = ref('');
22      const skillQuery = ref(''), skillCategory = ref('all');
23      const visibleSkills = computed(() => {
24        const terms = skillQuery.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
25        return skillCatalog.filter(skill => (skillCategory.value === 'all' || skill.category === skillCategory.value) && terms.every(term => skill.search.includes(term)));
26      });
27      const skillStatus = computed(() => `${visibleSkills.value.length} of ${skillCatalog.length} skills`);
28      const showSkill = id => visibleSkills.value.some(skill => skill.id === id);
29      function clearSkillFilters() { skillQuery.value = ''; skillCategory.value = 'all'; }
30      watch([skillQuery, skillCategory], () => nextTick(() => document.querySelectorAll('.skill-card').forEach(card => card.classList.add('is-visible'))));
31      const selectedPortraitId = ref('city'), favorites = ref([]), comparison = ref([]);
32      const galleryMode = ref('preview'), galleryStatus = ref(''), portraitDialog = ref(null);
33      const selectedPortrait = computed(() => portraits.find(photo => photo.id === selectedPortraitId.value) || portraits[3]);
34      const dialogPortraits = computed(() => galleryMode.value === 'compare' ? comparison.value.map(id => portraits.find(photo => photo.id === id)).filter(Boolean) : [selectedPortrait.value]);
35      let world, flare, revealObserver, scrollFrame, previousFocus, acquisitionChart, chartObserver, courseChart, courseChartObserver;
36      const listeners = new AbortController();
37      const signal = listeners.signal;
38
39      const showProject = categories => filter.value === 'all' || categories.split(' ').includes(filter.value);
40      function setFilter(value) {
41        filter.value = value;
42        const count = [...document.querySelectorAll('.project-card')].filter(card => showProject(card.dataset.projectCategories || '')).length;
43        const category = { all: '', ai: 'AI and agent ', platform: 'platform ', games: 'game ' }[value] || '';
44        filterStatus.value = `Showing ${count} ${category}project${count === 1 ? '' : 's'}.`;
45        nextTick(() => document.querySelectorAll('.project-card').forEach(card => card.classList.add('is-visible')));
46      }
47      function toggleMenu() {
48        menuOpen.value = !menuOpen.value;
49        if (menuOpen.value) nextTick(() => document.querySelector('#mobile-nav a')?.focus());
50      }
51      function closeMenu(returnFocus = false) {
52        menuOpen.value = false;
53        if (returnFocus) nextTick(() => document.querySelector('.menu-toggle')?.focus());
54      }
55      function handleMobileNav(event) {
56        const link = event.target.closest('a');
57        if (!link) return;
58        closeMenu();
59        nextTick(() => {
60          const heading = document.querySelector(link.getAttribute('href'))?.querySelector('h1, h2');
61          if (heading) { heading.setAttribute('tabindex', '-1'); heading.focus({ preventScroll: true }); }
62        });
63      }
64      function toggleMotion() { motionEnabled.value = !motionEnabled.value; }
65      watch(menuOpen, open => {
66        document.body.classList.toggle('menu-open', open);
67        const main = document.querySelector('main'), footer = document.querySelector('footer');
68        if (main) main.inert = open;
69        if (footer) footer.inert = open;
70      });
71      watch(motionEnabled, enabled => {
72        document.documentElement.classList.toggle('motion-paused', !enabled);
73        world?.setMotion(enabled);
74        flare?.setMotion(enabled);
75        if (acquisitionChart) acquisitionChart.setOption({ animation: enabled });
76        try { sessionStorage.setItem('shoemoney-motion', enabled ? 'on' : 'off'); } catch { /* storage is optional */ }
77      });
78      function toggleFavorite(id) {
79        favorites.value = favorites.value.includes(id) ? favorites.value.filter(value => value !== id) : [...favorites.value, id];
80        galleryStatus.value = favorites.value.includes(id) ? 'Portrait saved to your favorites on this device.' : 'Portrait removed from your favorites.';
81        try { localStorage.setItem('shoemoney-portrait-favorites', JSON.stringify(favorites.value)); } catch { galleryStatus.value = 'Favorite selected for this visit.'; }
82      }
83      function toggleCompare(id) {
84        if (comparison.value.includes(id)) comparison.value = comparison.value.filter(value => value !== id);
85        else if (comparison.value.length < 2) comparison.value = [...comparison.value, id];
86        else { galleryStatus.value = 'Two portraits are selected. Remove one before adding another.'; return; }
87        galleryStatus.value = comparison.value.length === 2 ? 'Two portraits selected. Open Compare to see them side by side.' : 'Choose another portrait to compare.';
88      }
89      async function openGallery(mode) {
90        if (mode === 'compare' && comparison.value.length !== 2) return;
91        galleryMode.value = mode;
92        previousFocus = document.activeElement;
93        await nextTick();
94        portraitDialog.value?.showModal();
95        document.body.classList.add('dialog-open');
96      }
97      function closeGallery() { portraitDialog.value?.close(); }
98      function afterDialogClose() { document.body.classList.remove('dialog-open'); previousFocus?.focus?.(); }
99      function closeOnBackdrop(event) { if (event.target === portraitDialog.value) closeGallery(); }
100      function updateScroll() {
101        scrollFrame = null;
102        const max = document.documentElement.scrollHeight - innerHeight;
103        scrollProgress.value = max > 0 ? Math.min(100, scrollY / max * 100) : 0;
104        const sections = [...document.querySelectorAll('main .section-anchor')];
105        let current = 'home';
106        for (const section of sections) if (section.getBoundingClientRect().top <= 150) current = section.id;
107        if (innerHeight + scrollY >= document.documentElement.scrollHeight - 10) current = 'contact';
108        activeSection.value = current;
109      }
110      onMounted(() => {
111        document.documentElement.classList.add('js-ready');
112        try {
113          if (!media.matches && sessionStorage.getItem('shoemoney-motion') === 'off') motionEnabled.value = false;
114          const saved = JSON.parse(localStorage.getItem('shoemoney-portrait-favorites') || '[]');
115          if (Array.isArray(saved)) favorites.value = saved.filter(id => portraits.some(photo => photo.id === id));
116        } catch { /* Local storage is not required. */ }
117        document.documentElement.classList.toggle('motion-paused', !motionEnabled.value);
118        const canvas = document.querySelector('#world-canvas');
119        if (canvas && window.ShoeMoneyWorld) world = new window.ShoeMoneyWorld(canvas, { motion: motionEnabled.value });
120        if (window.ShoeMoneyFlare) flare = new window.ShoeMoneyFlare({ motion: motionEnabled.value });
121        revealObserver = new IntersectionObserver(entries => entries.forEach(entry => {
122          if (entry.isIntersecting) { entry.target.classList.add('is-visible'); revealObserver.unobserve(entry.target); }
123        }), { threshold: .07, rootMargin: '0px 0px 30px 0px' });
124        document.querySelectorAll('.reveal').forEach(element => revealObserver.observe(element));
125        media.addEventListener('change', event => { motionEnabled.value = !event.matches; }, { signal });
126        window.addEventListener('scroll', () => { if (!scrollFrame) scrollFrame = requestAnimationFrame(updateScroll); }, { signal, passive: true });
127        window.addEventListener('resize', () => { if (innerWidth > 1280) closeMenu(); updateScroll(); }, { signal, passive: true });
128        window.addEventListener('keydown', event => {
129          if (!menuOpen.value) return;
130          if (event.key === 'Escape') { event.preventDefault(); closeMenu(true); return; }
131          if (event.key === 'Tab') {
132            const items = [document.querySelector('.menu-toggle'), ...document.querySelectorAll('#mobile-nav a')];
133            const first = items[0], last = items[items.length - 1];
134            if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
135            else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
136          }
137        }, { signal });
138        document.querySelectorAll('[data-tilt-art]').forEach(art => {
139          const frame = art.querySelector('.portrait-frame');
140          art.addEventListener('pointermove', event => {
141            if (!motionEnabled.value || event.pointerType !== 'mouse') return;
142            const bounds = art.getBoundingClientRect(), x = (event.clientX - bounds.left) / bounds.width - .5, y = (event.clientY - bounds.top) / boun
142ds.height - .5;
143            frame.style.transform = `rotate(2deg) rotateY(${x * 7}deg) rotateX(${-y * 5}deg)`;
144          }, { signal, passive: true });
145          art.addEventListener('pointerleave', () => { frame.style.transform = ''; }, { signal, passive: true });
146        });
147        // No renderer or animation survives page suspension; BFCache restores a fresh instance.
148        window.addEventListener('pagehide', () => { world?.destroy(); flare?.destroy(); }, { signal });
149        window.addEventListener('pageshow', event => {
150          if (event.persisted) {
151            world = new window.ShoeMoneyWorld(document.querySelector('#world-canvas'), { motion: motionEnabled.value });
152            flare = new window.ShoeMoneyFlare({ motion: motionEnabled.value });
153          }
154        }, { signal });
155        const courseElement = document.querySelector('#course-rating-chart');
156        if (courseElement && window.echarts) {
157          courseChart = echarts.init(courseElement, null, { renderer: 'svg' });
158          courseChart.setOption({
159            animation: false,
160            textStyle: { fontFamily: 'DM Sans, Arial, sans-serif', fontSize: 18 },
161            grid: { left: 12, right: 12, top: 10, bottom: 35 },
162            tooltip: {
163              trigger: 'item', confine: true, backgroundColor: '#111410', borderColor: '#75ead7',
164              textStyle: { color: '#f3f2e8', fontSize: 20 },
165              formatter: () => '3.9 out of 5<br>191 course ratings',
166            },
167            xAxis: { type: 'value', min: 0, max: 5, interval: 1,
168              axisLabel: { fontSize: 18, color: '#c1c9bd' }, axisLine: { show: false }, axisTick: { show: false },
169              splitLine: { show: false },
170            },
171            yAxis: { type: 'category', data: ['Course rating'], show: false },
172            series: [{ type: 'bar', data: [3.9], barWidth: 30, showBackground: true,
173              backgroundStyle: { color: '#ffffff15', borderRadius: 6 },
174              itemStyle: { color: '#75ead7', borderRadius: 6 },
175              emphasis: { itemStyle: { color: '#d7ff3f' } },
176            }],
177          });
178          courseChartObserver = new ResizeObserver(() => courseChart.resize());
179          courseChartObserver.observe(courseElement);
180        }
181        const chartElement = document.querySelector('#acquisition-chart');
182        if (chartElement && window.echarts) {
183          acquisitionChart = echarts.init(chartElement, null, { renderer: 'svg' });
184          const chartOption = () => ({
185            animation: motionEnabled.value,
186            animationDuration: 900,
187            textStyle: { fontFamily: 'DM Sans, Arial, sans-serif', fontSize: 18 },
188            aria: { enabled: true, decal: { show: false }, label: { description: "Acquisition values in US dollars: AuctionAds, acquired by MediaWhiz in 2007 for 17 million dollars. PAR Program, acquired by GoSocial in 2015 for 12 million dollars." } },
189            grid: { left: 135, right: 68, top: 20, bottom: 76 },
190            tooltip: {
191              trigger: 'axis', confine: true, backgroundColor: '#111410', borderColor: '#d7ff3f',
192              textStyle: { color: '#f3f2e8', fontSize: 20 },
193              axisPointer: { type: 'shadow', shadowStyle: { color: '#11141008' } },
194              formatter: params => {
195                const item = params[0], isAuction = item.dataIndex === 0;
196                return (isAuction ? 'AuctionAds · 2007' : 'PAR Program · 2015') + '<br><b>$' + item.value + ' million</b><br>Acquired by ' + (isAuction ? 'MediaWhiz' : 'GoSocial');
197              },
198            },
199            xAxis: { type: 'value', min: 0, max: 20, interval: chartElement.clientWidth < 500 ? 10 : 5,
200              name: 'Acquisition value · USD millions', nameLocation: 'middle', nameGap: 40,
201              nameTextStyle: { fontSize: 18, color: '#4e5549' },
202              axisLabel: { fontSize: 18, color: '#4e5549' }, axisLine: { show: false }, axisTick: { show: false },
203              splitLine: { lineStyle: { color: '#11141015' } },
204            },
205            yAxis: { type: 'category', inverse: true, data: ['AuctionAds\n2007', 'PAR Program\n2015'],
206              axisLine: { show: false }, axisTick: { show: false },
207              axisLabel: { fontSize: 18, lineHeight: 26, color: '#111410', margin: 15 },
208            },
209            series: [{ type: 'bar', barWidth: 40,
210              data: [{ value: 17, itemStyle: { color: '#4d653b' } }, { value: 12, itemStyle: { color: '#617b54' } }],
211              itemStyle: { borderRadius: [0, 5, 5, 0] },
212              emphasis: { itemStyle: { color: '#334925' } },
213              label: { show: true, position: 'right', formatter: '${c}M', fontSize: 21, fontWeight: 600, color: '#111410', distance: 10 },
214            }],
215          });
216          acquisitionChart.setOption(chartOption());
217          const chartDetails = document.querySelector('.chart-data');
218          if (chartDetails) chartDetails.open = false;
219          chartObserver = new ResizeObserver(() => {
220            acquisitionChart.resize();
221            acquisitionChart.setOption({ xAxis: { interval: chartElement.clientWidth < 500 ? 10 : 5 } });
222          });
223          chartObserver.observe(chartElement);
224        }
225        updateScroll();
226      });
227      onBeforeUnmount(() => {
228        listeners.abort(); revealObserver?.disconnect(); chartObserver?.disconnect(); acquisitionChart?.dispose(); courseChartObserver?.disconnect(); courseChart?.dispose(); cancelAnimationFrame(scrollFrame); world?.destroy(); flare?.destroy();
229        document.body.classList.remove('menu-open', 'dialog-open');
230      });
231      return { menuOpen, activeSection, scrollProgress, motionEnabled, filter, filterStatus, skillQuery, skillCategory, visibleSkills, skillStatus, showSkill, clearSkillFilters, portraits, selectedPortraitId, selectedPortrait, favorites, comparison, galleryMode, galleryStatus, portraitDialog, dialogPortraits, showProject, setFilter, toggleMenu, handleMobileNav, toggleMotion, toggleFavorite, toggleCompare, openGallery, closeGallery, afterDialogClose, closeOnBackdrop };
232    },
233  });
234  // Fallback labels are real HTML for no-JS readers. Vue's runtime compiler requires
235  // v-text targets to start empty, so remove only those labels immediately at mount.
236  document.querySelectorAll('#app [v-text]').forEach(element => { element.textContent = ''; });
237  app.mount('#app');
238})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.