[
  {
    "path": ".eslintrc",
    "content": "{\n  \"extends\": \"next\"\n}\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: npm\n  directory: \"/\"\n  schedule:\n    interval: daily\n"
  },
  {
    "path": ".github/workflows/automerge.yml",
    "content": "name: Enable automerge for dependabot PRs\n\non:\n  pull_request_target:\n\njobs:\n  merge-me:\n    name: Enable automerge for dependabot PRs\n    runs-on: ubuntu-latest\n    steps:\n      - name: Enable automerge for dependabot PRs\n        uses: daneden/enable-automerge-action@v1\n        with:\n          github-token: ${{ secrets.PAT }}\n"
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "name: Create issues from todos\n\non:\n  push:\n    branches:\n      - master\n\njobs:\n  todos:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v1\n      - name: todo-actions\n        uses: dtinth/todo-actions@master\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          TODO_ACTIONS_MONGO_URL: ${{ secrets.TODO_ACTIONS_MONGO_URL }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# See http://help.github.com/ignore-files/ for more about ignoring files.\n\n# dependencies\nnode_modules\nnpm-shrinkwrap.json\npackage-lock.json\n\n# production\nbuild\n.next\n\n# misc\n.DS_Store\nnpm-debug.log\nyarn-error.log\n\n# ignore generated data\npackage-lock.json\ndata/manifest.ts\n.now"
  },
  {
    "path": ".husky/.gitignore",
    "content": "_\n"
  },
  {
    "path": ".prettierrc.js",
    "content": "module.exports = {\n  semi: false,\n  trailingComma: \"es5\",\n}\n"
  },
  {
    "path": "README.md",
    "content": "# photos.daneden.me\n\nA place for my photos to shine (in more glory than Instagram can deliver).\n\n## How does this work?\n\nI used the amazing [create-react-app](https://github.com/facebookincubator/create-react-app) as a starting point for this site, and added a few things to make it my own.\n\n- Images are uploaded to the `public/images` folder\n- Next, there's the pre-start script [`exif.js`](https://github.com/daneden/photos.daneden.me/blob/master/scripts/exif.js). This Node script uses `node-exiftool` to loop over each image in the folder and extract exif data. The particular data I wanted to display was the aperture, shutter speed, ISO, and focal length. This data is dropped into `manifest.ts`, which is ignored by git to avoid too many sources of truth (in this case, the images remain the sources of truth).\n- [`index.tsx`](https://github.com/daneden/photos.daneden.me/blob/master/src/index.tsx) is what imports the data from `manifest.ts` and passes it as props to the images, which are rendered as React components.\n"
  },
  {
    "path": "components/App.tsx",
    "content": "import { ReactElement, ReactNode } from \"react\"\nimport altDescriptions from \"../data/altDescriptions.json\"\nimport GlobalStyles from \"./GlobalStyles\"\nimport Header from \"./Header\"\nimport Image, { Props as ImageProps } from \"./Image\"\n\ntype Props = {\n  preface?: ReactElement\n  images: Array<ImageProps>\n}\n\nfunction Preface({ children }: { children: ReactNode }): ReactElement {\n  return (\n    <>\n      <div className=\"pane pane--text\">\n        <GlobalStyles />\n        <Header />\n        {children}\n      </div>\n      <style jsx>{`\n        @media (orientation: landscape) {\n          .pane {\n            position: sticky;\n            left: 0;\n            top: 0;\n            transform: translateY(\n              calc(\n                max(\n                  min(var(--scroll-delta), 300) / 300 * var(--baseline) * -2,\n                  -2rem\n                )\n              )\n            );\n          }\n        }\n      `}</style>\n    </>\n  )\n}\n\nfunction App(props: Props): ReactElement {\n  return (\n    <>\n      <main className=\"site-content\">\n        <Preface>\n          <div className=\"content\">{props.preface}</div>\n        </Preface>\n        {props.images.map((img, i) => (\n          <Image\n            key={i}\n            aspectRatio={img.aspectRatio}\n            camera={img.camera}\n            fStop={img.fStop}\n            focalLength={img.focalLength}\n            iso={img.iso}\n            name={img.name}\n            speed={img.speed}\n            alt={altDescriptions[img.name]}\n            width={img.width}\n            height={img.height}\n          />\n        ))}\n      </main>\n      <style jsx>{`\n        .content {\n          opacity: calc((200 - var(--scroll-delta)) / 200);\n        }\n      `}</style>\n    </>\n  )\n}\n\nexport default App\n"
  },
  {
    "path": "components/GlobalStyles.tsx",
    "content": "export default function GlobalStyles() {\n  return (\n    <style jsx global>\n      {`\n        :root {\n          --imgSize: 85vh;\n          --baseline: 1.5rem;\n          --darkGray: #222;\n          --lightGray: #fefefe;\n          --foreground: var(--darkGray);\n          --background: var(--lightGray);\n        }\n\n        * {\n          padding: 0;\n          margin: 0;\n          box-sizing: border-box;\n          position: relative;\n        }\n\n        html {\n          -webkit-text-size-adjust: none;\n          background-color: var(--background);\n          color: var(--foreground);\n          font: 80%/1.5 system-ui, -apple-system, BlinkMacSystemFont, sans-serif;\n          height: 100%;\n          overflow: hidden;\n          transition: 0.3s ease;\n          transition-property: color, background-color;\n        }\n\n        @media (prefers-color-scheme: dark) {\n          html {\n            background-color: var(--foreground);\n            color: var(--background);\n          }\n        }\n\n        body {\n          display: flex;\n          height: 100%;\n          align-items: center;\n          overflow: scroll;\n          -webkit-overflow-scrolling: touch;\n        }\n\n        @media (orientation: portrait) {\n          html {\n            overflow: initial;\n          }\n\n          body {\n            height: auto;\n          }\n        }\n\n        a {\n          color: inherit;\n          text-decoration-color: var(--highlight);\n          transition: 0.2s ease;\n          transition-property: color, text-decoration-color;\n        }\n\n        a:hover,\n        a:focus {\n          color: var(--highlight);\n        }\n\n        p {\n          margin-bottom: calc(var(--baseline) / 2);\n        }\n\n        .frac {\n          font-variant-numeric: diagonal-fractions;\n        }\n\n        .site-title {\n          font-weight: 600;\n          font-size: 1rem;\n        }\n\n        #__next {\n          display: flex;\n          flex-flow: column nowrap;\n          padding-bottom: var(--baseline);\n        }\n\n        .site-content {\n          flex: 1 0 auto;\n          display: grid;\n          max-height: var(--imgSize);\n          grid-auto-flow: column;\n          align-items: stretch;\n        }\n\n        .pane {\n          flex: 1 0 auto;\n          width: auto;\n          vertical-align: top;\n          padding: calc(var(--baseline) / 2);\n          display: flex;\n          flex-direction: column;\n        }\n\n        @media (orientation: portrait) {\n          .site-root {\n            flex-flow: column wrap;\n            max-height: unset;\n          }\n\n          .site-content {\n            grid-auto-flow: row;\n            width: 100px;\n          }\n\n          .pane {\n            height: auto;\n            width: auto;\n            margin: 0;\n            max-width: 100vw;\n          }\n        }\n\n        .pane--text {\n          align-self: flex-start;\n          width: 28rem;\n          max-width: 100%;\n        }\n\n        @media (orientation: portrait) {\n          .pane--text {\n            flex-basis: auto;\n            width: 100vw;\n          }\n        }\n\n        .placeholder,\n        .image__img.ssr {\n          --aspect-ratio: 1;\n          background-color: rgba(0, 0, 0, 0.1);\n          max-height: var(--imgSize);\n          height: var(--imgSize);\n          width: calc(var(--imgSize) * var(--aspect-ratio));\n        }\n\n        @media (orientation: portrait) {\n          .placeholder,\n          .image__img.ssr {\n            --width: calc(100vw - var(--baseline));\n            width: var(--width);\n            height: calc(var(--width) / var(--aspect-ratio));\n          }\n        }\n      `}\n    </style>\n  )\n}\n"
  },
  {
    "path": "components/Header.tsx",
    "content": "import { ReactElement } from \"react\"\n\nconst Header = (): ReactElement => {\n  return (\n    <header className=\"site-header\">\n      <h1 className=\"site-title\">\n        photos.<a href=\"https://daneden.me\">daneden.me</a>\n      </h1>\n    </header>\n  )\n}\n\nexport default Header\n"
  },
  {
    "path": "components/Image.tsx",
    "content": "import NextImage from \"next/image\"\nimport * as React from \"react\"\n\nconst { useEffect, useState } = React\n\nexport type Props = {\n  aspectRatio: number\n  camera: string\n  alt: string\n  focalLength: string\n  fStop: number\n  iso: number\n  name: string\n  speed: string\n  width: number\n  height: number\n}\n\nfunction Image(props: Props) {\n  const [imageLoaded, setImageLoaded] = useState(false)\n\n  const {\n    aspectRatio,\n    camera,\n    alt: description,\n    fStop,\n    focalLength,\n    iso,\n    name,\n    width,\n    height,\n  } = props\n\n  const url = `/images/${name}`\n\n  const image = (\n    <NextImage\n      alt={description}\n      className={`image ${imageLoaded ? \"loaded\" : \"not-loaded\"}`}\n      height={height}\n      onLoad={() => setImageLoaded(true)}\n      src={url}\n      width={width}\n    />\n  )\n\n  const speed =\n    // If the shutter speed is a fraction, we want to style it appropriately.\n    props.speed.includes(\"/\") ? (\n      <span className=\"frac\">{props.speed}</span>\n    ) : (\n      props.speed\n    )\n\n  return (\n    <>\n      <div className=\"pane\">\n        <div className=\"image-container\">{image}</div>\n        <p>\n          {camera}, {`\\u0192${fStop}, `}\n          {speed} sec, {focalLength}, <span className=\"caps\">ISO</span> {iso}\n        </p>\n      </div>\n      <style jsx>{`\n        .pane {\n          --aspect-ratio: ${aspectRatio};\n          display: flex;\n          flex: 1 1 100%;\n          transition: 0.5s ease;\n          transition-property: transform, opacity;\n        }\n\n        .image-container {\n          height: var(--imgSize);\n          width: calc(var(--imgSize) * var(--aspect-ratio));\n        }\n\n        .pane :global(.image) {\n          border-radius: 4px;\n          display: block;\n          flex: 0 0 100%;\n          object-fit: cover;\n          object-position: center;\n          transition: 0.3s ease opacity;\n          opacity: 1;\n          background-color: rgba(0, 0, 0, 0.15);\n          height: var(--imgSize);\n          max-width: 100%;\n        }\n\n        @media (orientation: portrait) {\n          .pane {\n            height: auto;\n            width: auto;\n          }\n\n          .image-container {\n            width: 100%;\n            height: auto;\n          }\n        }\n      `}</style>\n    </>\n  )\n}\n\nexport default Image\n"
  },
  {
    "path": "components/types.d.ts",
    "content": "declare module \"*.woff2\"\ndeclare module \"*.woff\"\n"
  },
  {
    "path": "data/altDescriptions.json",
    "content": "{\n  \"00013.jpg\": \"A man standing in a warmly-lit room and taking a photograph of something out of frame. Behind him is a long, daylight-drenched and spacious corridor. Close by, a woman stares at something else out of frame, and in the very far distance, green trees can be seen through a frosted window.\",\n  \"00015.jpg\": \"A woman crouches near a body of water at sunrise to take a photograph of a city in the distance.\",\n  \"00016.jpg\": \"The interior of Chicago’s Natural History Museum, looking down on tourists in the atrium. A group of people stand around a Tyrannosaurus Rex skeleton, looking at a map.\",\n  \"00021.jpg\": \"A brown horse eating grass on a sunny day. In the distant background, an earthy hill is peppered with small buildings, just beyond a shallow body of water.\",\n  \"00028.jpg\": \"\",\n  \"00030.jpg\": \"\",\n  \"00031.jpg\": \"\",\n  \"00034.jpg\": \"\",\n  \"00035.jpg\": \"\",\n  \"00036.jpg\": \"\",\n  \"00037.jpg\": \"\",\n  \"00039.jpg\": \"\",\n  \"00041.jpg\": \"\",\n  \"00043.jpg\": \"\",\n  \"00045.jpg\": \"\",\n  \"00046.jpg\": \"\",\n  \"00047.jpg\": \"\",\n  \"00048.jpg\": \"\",\n  \"00049.jpg\": \"\",\n  \"00050.jpg\": \"\",\n  \"00051.jpg\": \"\",\n  \"00052.jpg\": \"\",\n  \"00053.jpg\": \"\",\n  \"00054.jpg\": \"The exterior of a modern residential building in Barcelona, with concrete balconies overflowing with vibrant green plants.\",\n  \"00055.jpg\": \"The interior of London’s Natural History Museum’s atrium. Light cascades in through windows in the ceiling; a blue whale’s skeleton is in the center of the frame, with dozens of people walking around the atrium and looking at the exhibits.\",\n  \"00056.jpg\": \"A field of wildflowers; vibrant spots of red poppies, yellow, lilac, and purple flowers pepper a green field.\"\n}\n"
  },
  {
    "path": "data/meta.tsx",
    "content": "const siteInfo = {\n  title: \"Dan Eden \\u2014 Photos\",\n  description:\n    \"Photography by Daniel Eden, a designer living in Manchester, UK.\",\n  fullDescription: (\n    <>\n      <p>\n        Dan Eden is a Designer from Manchester, England. He prefers to talk in\n        the first person.\n      </p>\n\n      <p>\n        Amongst thousands of photos, it&rsquo;s easy to lose track of my\n        favorites. This little website serves as a home for the photos I&rsquo;m\n        most proud of.\n      </p>\n\n      <p>\n        You can follow me on <a href=\"https://twitter.com/_dte\">Twitter</a> and{\" \"}\n        <a href=\"https://instagram.com/_dte\">Instagram</a>.\n      </p>\n    </>\n  ),\n  image: \"https://photos.daneden.me/images/00013.jpg\",\n}\n\nexport default siteInfo\n"
  },
  {
    "path": "hooks/useMatchMedia.ts",
    "content": "import { useEffect, useState } from \"react\"\n\nexport default function useMatchMedia(query) {\n  const mq = window.matchMedia !== undefined ? window.matchMedia(query) : null\n  const [matches, setMatches] = useState(mq !== null ? mq.matches : false)\n\n  useEffect(() => {\n    function updateMqMatches(mediaQueryList) {\n      setMatches(mediaQueryList.matches)\n    }\n\n    if (mq !== null) {\n      mq.addListener(updateMqMatches)\n\n      return () => {\n        mq.removeListener(updateMqMatches)\n      }\n    }\n  }, [mq])\n\n  return matches\n}\n"
  },
  {
    "path": "next-env.d.ts",
    "content": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edited\n// see https://nextjs.org/docs/basic-features/typescript for more information.\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"photos\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"devDependencies\": {\n    \"@types/node\": \"^18.7.14\",\n    \"@types/react\": \"^18.0.18\",\n    \"@types/react-dom\": \"^18.0.6\",\n    \"eslint\": \"^8.52.0\",\n    \"eslint-config-next\": \"^14.0.0\",\n    \"husky\": \"8.0.1\",\n    \"lint-staged\": \"13.0.3\",\n    \"prettier\": \"2.7.1\",\n    \"typescript\": \"^5.2.2\"\n  },\n  \"husky\": {\n    \"hooks\": {\n      \"pre-commit\": \"lint-staged\"\n    }\n  },\n  \"lint-staged\": {\n    \"*.{js,json,css,md}\": [\n      \"prettier --write\"\n    ]\n  },\n  \"dependencies\": {\n    \"dist-exiftool\": \"10.53.0\",\n    \"next\": \"^14.0.0\",\n    \"node-exiftool\": \"2.3.0\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\"\n  },\n  \"scripts\": {\n    \"prestart\": \"node ./scripts/exif.js\",\n    \"prebuild\": \"node ./scripts/exif.js\",\n    \"dev\": \"next\",\n    \"build\": \"next build\",\n    \"start\": \"next start\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  }\n}\n"
  },
  {
    "path": "pages/_document.tsx",
    "content": "import Document, { Head, Html, Main, NextScript } from \"next/document\"\n\nclass MyDocument extends Document {\n  render() {\n    return (\n      <Html lang=\"en\">\n        <Head />\n        <body>\n          <Main />\n          <NextScript />\n        </body>\n      </Html>\n    )\n  }\n}\n\nexport default MyDocument\n"
  },
  {
    "path": "pages/index.tsx",
    "content": "import Head from \"next/head\"\nimport React, { useEffect } from \"react\"\nimport App from \"../components/App\"\nimport imageData from \"../data/manifest\"\nimport siteInfo from \"../data/meta\"\n\nconst images = imageData.slice().reverse()\n\nfunction HomePage() {\n  useEffect(() => {\n    let content: HTMLElement = document.body\n    window.addEventListener(\"mousewheel\", scrollHandler)\n\n    function scrollHandler(e) {\n      if (content === undefined) {\n        content = document.body\n      } else {\n        content.scrollLeft += e.deltaY\n        content.setAttribute(\"style\", `--scroll-delta: ${content.scrollLeft}`)\n      }\n    }\n\n    return () => {\n      window.removeEventListener(\"mousewheel\", scrollHandler)\n    }\n  })\n\n  return (\n    <>\n      <Head>\n        <title>{siteInfo.title}</title>\n        <meta name=\"description\" content={siteInfo.description} />\n\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\n        <meta name=\"twitter:card\" content=\"summary_large_image\" />\n        <meta name=\"twitter:site\" content=\"@_dte\" />\n        <meta property=\"og:title\" content=\"Daniel Eden &mdash; Photography\" />\n        <meta property=\"og:type\" content=\"website\" />\n        <meta\n          property=\"og:image\"\n          content=\"https://photos.daneden.me/images/00013.jpg\"\n        />\n        <meta property=\"og:description\" content={siteInfo.description} />\n      </Head>\n\n      <App preface={siteInfo.fullDescription} images={images} />\n    </>\n  )\n}\n\nexport default HomePage\n"
  },
  {
    "path": "scripts/exif.js",
    "content": "\"use strict\"\nconst fs = require(\"fs\")\nconst exiftool = require(\"node-exiftool\")\nconst exiftoolBin = require(\"dist-exiftool\")\nconst ep = new exiftool.ExiftoolProcess(exiftoolBin)\n\nconst CAMERAS = {\n  SONY: {\n    \"ILCE-6000\": \"Sony a6000\",\n  },\n  \"LEICA CAMERA AG\": {\n    \"LEICA Q (Typ 116)\": \"Leica Q\",\n    \"LEICA SL2-S\": \"Leica SL2-S\",\n  },\n}\n\nasync function createManifestFromExifData(exifData) {\n  let fileInfo = []\n\n  // Transform the data to remove all but the info we care about\n  exifData.data.forEach(async (datum) => {\n    // The aspect ratio here is actually in terms of\n    // height:width (instead of typical width:height)\n    // since they all have a fixed height relative to the\n    // viewport\n    const [width, height] = datum.ImageSize.split(\"x\").map((n) => parseInt(n))\n    const aspectRatio = [width, height].reduce((w, h) => w / h)\n\n    const info = {\n      aspectRatio,\n      camera: CAMERAS[datum.Make][datum.Model] ?? datum.Make,\n      fStop: datum.FNumber || 16,\n      name: datum.FileName,\n      // I only have one manual lens, but this ternary is a hacky workaround.\n      focalLength: datum.FocalLength\n        ? datum.FocalLength.replace(\" \", \"\")\n        : \"12mm\",\n      iso: datum.ISO,\n      speed: String(datum.ShutterSpeed),\n      alt: datum.Description || \"\",\n      width,\n      height,\n    }\n\n    fileInfo.push(info)\n  })\n\n  // Sort the image data by filename\n  fileInfo.sort((a, b) => {\n    let keyA = parseInt(a.name.split(\".\")[0]),\n      keyB = parseInt(b.name.split(\".\")[0])\n    if (keyA < keyB) return -1\n    if (keyA > keyB) return 1\n    return 0\n  })\n\n  // Write data to file for the app to consume\n  let writeString = `import { Props as ImageData } from \"../components/Image\"\nconst imageData: Array<ImageData> = ${JSON.stringify(fileInfo, null, \" \")}\nexport default imageData`\n\n  fs.writeFile(\"./data/manifest.ts\", writeString, (err) => {\n    if (err) return console.log(err)\n  })\n}\n\nep.open()\n  .then((pid) => {\n    console.log(`🏁  Started exiftool process (PID: ${pid})`)\n    console.log(\"📸  Extracting photo metadata...\")\n    return ep\n      .readMetadata(\"./public/images/\")\n      .then(async (res) => {\n        await createManifestFromExifData(res)\n      })\n      .catch((error) => {\n        console.log(\"Error: \", error)\n      })\n  })\n  .then(() => {\n    return ep.close().then(() => {\n      console.log(\"✅  Metadata extracted! Closing exiftool.\")\n    })\n  })\n  .catch((error) => {\n    console.error(\"🚨  Error extracting photo metadata!\", error)\n  })\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": false,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true\n  },\n  \"exclude\": [\n    \"node_modules\"\n  ],\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\"\n  ]\n}\n"
  }
]