├── .gitignore ├── .npmrc ├── README.md ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── studio ├── .env.production ├── .eslintrc ├── .gitignore ├── actions │ └── actions.js ├── components │ ├── CustomGuide.jsx │ └── CustomSlugInput.jsx ├── package.json ├── sanity.cli.js ├── sanity.config.js ├── schemas │ ├── documents │ │ ├── category.js │ │ ├── global.js │ │ ├── guide.js │ │ ├── homepage.js │ │ ├── legal.js │ │ ├── page.js │ │ ├── project.jsx │ │ └── projectsOverview.js │ ├── index.js │ ├── modules │ │ ├── gallery.js │ │ ├── singleMedia.js │ │ └── textBlock.js │ ├── objects │ │ ├── blockContent.jsx │ │ ├── cta.js │ │ ├── mainImage.js │ │ ├── mainVideo.js │ │ ├── media.jsx │ │ ├── mediaGallery.js │ │ ├── mediaWithMobile.jsx │ │ ├── redirect.js │ │ ├── seo.js │ │ ├── slugField.js │ │ └── socialLink.js │ └── references │ │ ├── author.js │ │ ├── category.js │ │ └── client.js ├── structure.js └── utils │ ├── sanityConstants.js │ └── sanityHelper.jsx └── web ├── .env.example ├── .eslintrc.cjs ├── .prettierrc ├── assets └── styles │ ├── fallback.css │ ├── fonts.css │ ├── main.css │ └── normalize.css ├── components ├── Footer.vue ├── Header │ ├── Header.vue │ ├── NavBtn.vue │ ├── NavDesktop.vue │ └── NavMobile.vue ├── Icons │ └── .gitkeep ├── Modules.vue ├── Modules │ ├── gallery.vue │ └── textBlock.vue ├── Partials │ ├── BlockContent.vue │ ├── BlockContentLink.vue │ ├── Cta.vue │ ├── Img.vue │ ├── Media.vue │ └── Video.vue ├── PreviewBanner.vue └── ProjectPreview.vue ├── composables ├── states.js ├── use-credits.js ├── use-detect-browser.js ├── use-in-view.js ├── use-key.js ├── use-sanity-data.js ├── use-sanity-preview.js ├── use-seo.js └── use-size.js ├── error.vue ├── functions └── template.js ├── layouts ├── default.vue └── empty.vue ├── netlify.toml ├── nuxt.config.ts ├── package.json ├── pages ├── [page].vue ├── functions.vue ├── index.vue └── work │ ├── [project].vue │ └── index.vue ├── plugins ├── gsap.js └── sanity-image-builder.js ├── providers └── mux-provider.ts ├── public ├── _redirects ├── favicon.png └── fonts │ └── GT.woff2 ├── tsconfig.json └── utils ├── constants.js ├── helper.js ├── queries.js └── transitions.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | .DS_Store 10 | # Local Netlify folder 11 | .netlify 12 | .data 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Baustelle 2 | 3 | Anna's starter template with Nuxt 3 and Sanity v3. 👷‍♀️ 4 | Demo → [baustelle.erdelen.com](https://baustelle.erdelen.com/) 5 | 6 | ## ● Features 7 | 8 | - [Nuxt 3](https://nuxt.com/) 9 | - [Sanity v3](https://sanity.io) 10 | - [Tailwind](https://tailwindcss.com/) 11 | - [GSAP](https://greensock.com/gsap/) 12 | - Video Hosting with [Mux](https://www.mux.com/) 13 | - Optimized Images with [Nuxt Image](https://image.nuxt.com/) 14 | - [PNPM](https://pnpm.io/) Workspaces 15 | - Preview Functionality 16 | 17 | ## ● Getting Started 18 | 19 | → Create a new Sanity Project 20 | 21 | ```bash 22 | cd studio/ 23 | pnpm create sanity@latest 24 | ``` 25 | 26 | - Abort with `ctrl + c` when "Project output path" appears 27 | - Get Project ID with `sanity manage` or `sanity projects list` 28 | - Change ID in `web/.env`, `studio/sanity.config` and `studio/sanity.cli` 29 | 30 | → Install Dependencies 31 | 32 | ```bash 33 | # From Root 34 | pnpm install 35 | ``` 36 | 37 | → Start Development Server 38 | 39 | ```bash 40 | # From Root 41 | # Nuxt → http://localhost:3000 42 | # Sanity → http://localhost:3333 43 | pnpm dev 44 | ``` 45 | 46 | → Netlify Serverless Functions 47 | 48 | ```bash 49 | # Install Netlify CLI 50 | # From Root, Select web 51 | # http://localhost:8888 52 | ntl dev 53 | ``` 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "baustelle", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "clean": "find ./ -name node_modules -type d -exec rm -rf {} +", 6 | "dev": "pnpm -r run dev", 7 | "web:dev": "pnpm -F web run dev", 8 | "studio:dev": "pnpm -F studio run dev" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'web' 3 | - 'studio' 4 | -------------------------------------------------------------------------------- /studio/.env.production: -------------------------------------------------------------------------------- 1 | SANITY_STUDIO_PREVIEW_URL=https://baustelle.erdelen.com/ -------------------------------------------------------------------------------- /studio/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/eslint-config-studio" 3 | } 4 | -------------------------------------------------------------------------------- /studio/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # Compiled Sanity Studio 9 | /dist 10 | 11 | # Temporary Sanity runtime, generated by the CLI on every dev server start 12 | /.sanity 13 | 14 | # Logs 15 | /logs 16 | *.log 17 | 18 | # Coverage directory used by testing tools 19 | /coverage 20 | 21 | # Misc 22 | .DS_Store 23 | *.pem 24 | 25 | # Typescript 26 | *.tsbuildinfo 27 | 28 | # Dotenv and similar local-only files 29 | *.local 30 | -------------------------------------------------------------------------------- /studio/actions/actions.js: -------------------------------------------------------------------------------- 1 | import { FiEye } from 'react-icons/fi'; 2 | 3 | export function PreviewAction(props) { 4 | if (props.id === 'global' || props.id === 'guide') return; 5 | 6 | //TODO set url 7 | const previewUrl = process.env.SANITY_STUDIO_PREVIEW_URL || 'http://localhost:3000'; 8 | 9 | return { 10 | label: 'Open Preview', 11 | icon: FiEye, 12 | onHandle: () => { 13 | const doc = props.draft || props.published; 14 | let slug; 15 | 16 | switch (doc._type) { 17 | case 'homepage': 18 | slug = ''; 19 | break; 20 | case 'about': 21 | slug = '/about'; 22 | break; 23 | default: 24 | slug = '/' + (doc.slug?.current || ''); 25 | } 26 | 27 | // test page preview urls 28 | const url = `${previewUrl}${slug}?preview=true`; 29 | window.open(url, '_blank'); 30 | }, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /studio/components/CustomGuide.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/alt-text */ 2 | /* eslint-disable react/no-unescaped-entities */ 3 | /* eslint-disable react/react-in-jsx-scope */ 4 | import { Container, Box, Text } from '@sanity/ui'; 5 | 6 | const spacing = { between: 7 }; 7 | 8 | export const CustomGuide = () => { 9 | return ( 10 | 11 | 12 | 13 | 🛎️ Services 14 | 15 | 16 |
17 | Sanity 18 |

19 | 20 | Sanity 21 | {' '} 22 | is a platform for structured content. It comes with an open source editing environment called{' '} 23 | 24 | Sanity Studio 25 | 26 | . This is the content mangement system (CMS) we use and where you are currently located. The Studio is fully customized to your needs 27 | and can be extended by a developer at any time. All of the content such as text, files and images are hosted on their servers, which we 28 | use to create a bespoke digital platform. 29 |

30 |

31 | We use Sanity in the free (pay-as-you-go) version. If you would like to extend the feature set, have a look at the{' '} 32 | 33 | Growth Plan 34 | 35 | , which enables AI Assist, Multiple user roles, Scheduled publishing or Comments. 36 |

37 |
38 |
39 | Mux 40 |

41 | 42 | Mux 43 | {' '} 44 | is a video hosting platform that specializes in the processing and playback of video and audio. The platform therefore enables us to 45 | integrate videos into the website particularly fast and efficiently. Mux automatically adjusts the video quality to the user's bandwidth 46 | and only loads videos piece by piece rather than completely, which means that videos can be played and loaded very quickly. 47 |

48 |

49 | It also has excellent integration with Sanity. To upload video files, just select "Video" in the media field and upload the video 50 | directly to Sanity (it is handled by Mux under the hood). Mux automatically provides a preview image of the first frame of the video, 51 | which we display on the website while the video is loading. 52 |

53 |
54 |
55 | Netlify 56 |

57 | 58 | Netlify 59 | {' '} 60 | is a next-generation web hosting service. It works by being permanently connected to the website's code repository and running a build 61 | process through this connection when it detects updates to the codebase or when content is published. This process converts the code 62 | into static HTML, CSS and JS files, which are then served to website visitors via a global network of servers (called Content Delivery 63 | Network, CDN). This means that users receive a cached version of the websites from the server closest to them, which reduces load times 64 | enormously. 65 |

66 |
67 |
68 |
69 | 70 | 71 | 72 | 🚀 Publishing Content 73 | 74 | 75 |

76 | Don't forget to hit »Publish« on the bottom right after you've added content.
Every time you publish content, the website re-builds 77 | on Netlify. This means that changes will be live after 30 - 50 seconds. Have a look in your{' '} 78 | Dashboard to monitor this process. 79 |

80 |
81 |
82 | 83 | 84 | 85 | 👀 Preview Mode 86 | 87 | 88 |

89 | Preview mode allows you to see changes before publishing. Navigate to the page you want to preview and click on the the three dots in the 90 | bottom right corner. Then click on »Open Preview«. This will open the page in a new tab and a red preview banner at the bottom appears. 91 |

92 |

93 | After you edited content, you don't need to click »Publish«, just refresh the page and the draft changes appear 94 | immediately. Remember, any changes made in preview mode will not be visible to the public until you hit the »Publish« button. 95 |

96 |

Please be aware of the following:

97 |
    98 |
  • You need to be logged in to your Sanity Studio in the same browser as the preview.
  • 99 |
  • Preview mode does not work for content that can be found in global settings.
  • 100 |
  • New pages need to be published once before they can be previewed.
  • 101 |
  • This feature does not work in Firefox currently.
  • 102 |
  • It may increase API requests, which are limited in the free version.
  • 103 |
104 |
105 |
106 | 107 | 108 | 109 | 📄 Working with PDFs 110 | 111 | 112 |

113 | It is also possible to link files such as PDFs within a text. To do this, please highlight the word to be linked in the text editor and 114 | select "External Link". If you already have a URL to the file, paste it into the field. If not, PDFs can also be hosted with Sanity; 115 | upload the file to the media library, click on it, and select "Copy URL" at the bottom left. 116 |

117 |
118 |
119 | 120 | 121 | 122 | 🧑 Inviting Project Members 123 | 124 | 125 |

Click on your avatar at the top right and go to »Invite members«.

126 |
127 |
128 | 129 | 130 | 131 | 🔎 SEO 132 | 133 | 134 |

135 | I have made every effort to technically optimize the website for search engine optimization. If you have any requests for adjustments in 136 | this regard, please contact me. Nevertheless, content is the most important part of SEO. Simple tips to follow: 137 |

138 |
    139 |
  • 140 | Image optimization
    141 | Use meaningful image descriptions. Not »Image of a dog«, but rahter »A cute golden retriever puppy plays in the sunlight in the garden 142 | while happily looking at a toy ball«. 143 |
  • 144 |
  • 145 | Optimize meta tags
    146 | Consider writing meta descriptions for each page. Your can do this by opening the »SEO« field at the top of every page. 147 |
  • 148 |
  • 149 | Relevant keywords
    150 | Identify keywords that fit your business. Integrate them naturally into your website texts, headings and meta descriptions. 151 |
  • 152 |
  • 153 | Qualitative content
    154 | Create high-quality, unique content. Google prefers informative and useful texts. Make sure that your content answers the questions of 155 | your target group. 156 |
  • 157 |
  • 158 | Relevant links
    159 | The text you use for an internal link (anchor text) should be relevant to the linked content. Avoid generic phrases like "click here" 160 | and instead choose descriptive words that reflect the content of the target page. 161 |
  • 162 |
163 |
164 |
165 | 166 | 167 | 168 | 🖼️ Image Upload 169 | 170 | 171 |

172 | Images uploaded to Sanity are automatically compressed and converted to the WebP format. On the frontend, I implement lazy loading, which 173 | ensures that images are only loaded when they are needed or enter the viewport. Additionally, photos are rendered in multiple sizes and 174 | automatically adjusted to match the specific device being used. 175 |

176 |

These are some recommendations that work well in most cases:

177 |
    178 |
  • Aim for file sizes under 1MB. 2-3 MB can be tolerated, but smaller files are better for performance
  • 179 |
  • A maximum of 4000px on the longest side. For most purposes, a width of 2000-3000 px is sufficient
  • 180 |
  • Consider uploading lightly compressed images to achieve a smaller file size
  • 181 |
182 |
183 |
184 | 185 | 186 | 187 | 🎥 Video Upload 188 | 189 | 190 |

Video uploaded to Sanity are automatically compressed and optimized for various devices and bandwidths by Mux.

191 |

These are some recommendations that work well in most cases:

192 |
    193 |
  • Upload uncompressed or lightly compressed videos in MP4 (H.264 Video Codec)
  • 194 | {/*
  • File format: MP4 (H.264 Video Codec and AAC Audio Codec)
  • */} 195 |
  • 196 | Full HD (1920x1080) is a common standard and offers a good balance between quality and file size. If higher quality is required, you can 197 | also use 4K (3840x2160) 198 |
  • 199 |
  • A file size of under 100 MB per file is practical, as upload times could otherwise become very long
  • 200 | {/*
  • AAC audio codec, at least 128 kbps
  • */} 201 |
202 |
203 |
204 |
205 | ); 206 | }; 207 | -------------------------------------------------------------------------------- /studio/components/CustomSlugInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SlugInput } from 'sanity'; 3 | import { Container, Text } from '@sanity/ui'; 4 | 5 | export const CustomSlugInput = ({ basePath = '', ...props }) => { 6 | const getBasePath = () => { 7 | if (basePath) return `${basePath}/`; 8 | return ''; 9 | }; 10 | 11 | return ( 12 | 13 |
14 | 15 | www.your-website.com/{getBasePath()} 16 | 17 |
18 | 19 |
20 |
21 |
22 | ); 23 | }; 24 | 25 | // USE 26 | // { 27 | // name: 'slug', 28 | // type: 'slug', 29 | // description: 'Is a part of the URL that serves as an unique identifier of the page.', 30 | // options: { source: 'title', slugify }, 31 | // validation: slugValidation, 32 | // components: { 33 | // input: (props) => CustomSlugInput({ ...props, basePath: 'work-overview' }), 34 | // }, 35 | // }, 36 | 37 | // { 38 | // name: 'slug', 39 | // type: 'slugField', 40 | // }, 41 | -------------------------------------------------------------------------------- /studio/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "studio", 3 | "private": true, 4 | "version": "1.0.0", 5 | "main": "package.json", 6 | "license": "UNLICENSED", 7 | "scripts": { 8 | "dev": "sanity dev", 9 | "start": "sanity start", 10 | "build": "sanity build", 11 | "deploy": "sanity deploy", 12 | "deploy-graphql": "sanity graphql deploy" 13 | }, 14 | "keywords": [ 15 | "sanity" 16 | ], 17 | "dependencies": { 18 | "@sanity/dashboard": "^4.1.0", 19 | "@sanity/orderable-document-list": "^1.3.4", 20 | "@sanity/vision": "^3.61.0", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "react-icons": "^5.0.1", 24 | "react-is": "^18.2.0", 25 | "sanity": "^3.88.2", 26 | "sanity-plugin-dashboard-widget-netlify": "^2.0.1", 27 | "sanity-plugin-media": "^3.0.2", 28 | "sanity-plugin-mux-input": "^2.7.0", 29 | "styled-components": "^6.1.13" 30 | }, 31 | "devDependencies": { 32 | "@sanity/eslint-config-studio": "^3.0.0", 33 | "@types/react": "^18.0.25", 34 | "@types/styled-components": "^5.1.26", 35 | "eslint": "^8.6.0", 36 | "prettier": "^3.0.2", 37 | "typescript": "^4.9.5" 38 | }, 39 | "prettier": { 40 | "semi": true, 41 | "printWidth": 150, 42 | "bracketSpacing": true, 43 | "singleQuote": true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /studio/sanity.cli.js: -------------------------------------------------------------------------------- 1 | import { defineCliConfig } from 'sanity/cli'; 2 | 3 | export default defineCliConfig({ 4 | api: { 5 | projectId: 'hnuo52b9', 6 | dataset: 'production', 7 | }, 8 | //TODO 9 | // studioHost: 'baustelle', 10 | }); 11 | -------------------------------------------------------------------------------- /studio/sanity.config.js: -------------------------------------------------------------------------------- 1 | import { theme } from 'https://themer.sanity.build/api/hues?default=929292&primary=929292&transparent=929292&positive=darkest:101112&caution=darkest:101112&critical=darkest:101112&darkest=000000'; 2 | import { defineConfig, isDev } from 'sanity'; 3 | import { structureTool } from 'sanity/structure'; 4 | import { visionTool } from '@sanity/vision'; 5 | import { schemaTypes } from './schemas'; 6 | import { structure } from './structure'; 7 | import { media, mediaAssetSource } from 'sanity-plugin-media'; 8 | import { muxInput } from 'sanity-plugin-mux-input'; 9 | import { netlifyWidget } from 'sanity-plugin-dashboard-widget-netlify'; 10 | import { dashboardTool } from '@sanity/dashboard'; 11 | import { PreviewAction } from './actions/actions'; 12 | 13 | //TODO 14 | const singletons = ['media.tag', 'guide', 'global', 'homepage']; 15 | 16 | export default defineConfig({ 17 | theme, 18 | 19 | name: 'default', 20 | title: 'Studio', 21 | 22 | projectId: 'hnuo52b9', 23 | dataset: 'production', 24 | 25 | document: { 26 | actions: (prev, context) => { 27 | if (singletons.includes(context.documentId)) { 28 | const filteredActions = prev.filter((item) => !['unpublish', 'delete', 'duplicate'].includes(item.action)); 29 | return [...filteredActions, PreviewAction]; 30 | } 31 | 32 | return [...prev, PreviewAction]; 33 | }, 34 | }, 35 | 36 | plugins: [ 37 | structureTool({ structure }), 38 | media(), 39 | muxInput(), 40 | dashboardTool({ 41 | widgets: [ 42 | //TODO 43 | netlifyWidget({ 44 | title: 'Netlify Deploys', 45 | description: 46 | 'Because this website is statically built, it needs to be re-build and re-deployed to see the changes when content is published. You can check if the build was successful:', 47 | sites: [ 48 | { 49 | title: 'Website', 50 | apiId: 'yyyyy-xxxxx-zzzz-xxxx-yyyyyyyy', 51 | buildHookId: 'yyyyxxxxxyyyxxdxxx', 52 | name: 'sanity-gatsby-blog-20-web', 53 | url: 'https://my-sanity-deployment.com', 54 | }, 55 | ], 56 | }), 57 | // projectUsersWidget({ layout: 'medium' }), 58 | ], 59 | }), 60 | ...(isDev ? [visionTool()] : []), 61 | ], 62 | 63 | schema: { 64 | types: schemaTypes, 65 | templates: (prev) => [...prev.filter((el) => !singletons.includes(el.schemaType))], 66 | }, 67 | 68 | // Show Media Library on image selection 69 | form: { 70 | image: { 71 | assetSources: () => [mediaAssetSource], 72 | }, 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /studio/schemas/documents/category.js: -------------------------------------------------------------------------------- 1 | import { FiTag } from 'react-icons/fi'; 2 | 3 | export default { 4 | title: 'Category', 5 | name: 'category', 6 | type: 'document', 7 | icon: FiTag, 8 | fields: [ 9 | { 10 | title: 'Title', 11 | name: 'title', 12 | type: 'string', 13 | }, 14 | ], 15 | preview: { 16 | select: { 17 | title: 'title', 18 | }, 19 | prepare({ title }) { 20 | return { 21 | title, 22 | media: FiTag, 23 | }; 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /studio/schemas/documents/global.js: -------------------------------------------------------------------------------- 1 | import { PAGES } from '../../utils/sanityConstants'; 2 | 3 | export default { 4 | type: 'document', 5 | name: 'global', 6 | title: 'Global Settings', 7 | groups: [ 8 | { 9 | name: 'seo', 10 | title: 'SEO', 11 | }, 12 | { name: 'content' }, 13 | { name: 'redirects' }, 14 | ], 15 | fields: [ 16 | { 17 | name: 'siteTitle', 18 | type: 'string', 19 | validation: (Rule) => Rule.required(), 20 | group: 'seo', 21 | }, 22 | { 23 | name: 'metaDescription', 24 | type: 'text', 25 | validation: (Rule) => Rule.max(155).warning('Should be under 155 characters').required(), 26 | group: 'seo', 27 | }, 28 | { 29 | name: 'favicon', 30 | type: 'image', 31 | group: 'seo', 32 | }, 33 | // { 34 | // title: 'Web Clip', 35 | // name: 'webClip', 36 | // type: 'image', 37 | // group: 'seo', 38 | // }, 39 | { 40 | title: 'Open Graph Image', 41 | name: 'ogImage', 42 | type: 'image', 43 | group: 'seo', 44 | description: '1200x630 recommended', 45 | }, 46 | { 47 | name: 'navigation', 48 | title: 'Navigation/Pages', 49 | type: 'array', 50 | of: [ 51 | { 52 | type: 'reference', 53 | to: PAGES, 54 | title: 'Pages', 55 | }, 56 | ], 57 | group: 'content', 58 | }, 59 | { 60 | name: 'email', 61 | type: 'string', 62 | group: 'content', 63 | }, 64 | { 65 | name: 'socials', 66 | type: 'array', 67 | of: [{ type: 'socialLink' }], 68 | group: 'content', 69 | }, 70 | { 71 | name: 'copyright', 72 | type: 'string', 73 | description: 'Do not include years.', 74 | group: 'content', 75 | }, 76 | ], 77 | preview: { 78 | prepare() { 79 | return { 80 | title: 'Global Settings', 81 | }; 82 | }, 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /studio/schemas/documents/guide.js: -------------------------------------------------------------------------------- 1 | import { CustomGuide } from '../../components/CustomGuide'; 2 | 3 | export default { 4 | type: 'document', 5 | name: 'guide', 6 | fields: [ 7 | { 8 | name: 'title', 9 | type: 'string', 10 | hidden: true, 11 | }, 12 | { 13 | name: 'guide', 14 | type: 'text', 15 | components: { 16 | field: CustomGuide, 17 | }, 18 | }, 19 | ], 20 | preview: { 21 | prepare() { 22 | return { 23 | title: 'Guide', 24 | }; 25 | }, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /studio/schemas/documents/homepage.js: -------------------------------------------------------------------------------- 1 | import { FiHome } from 'react-icons/fi'; 2 | 3 | export default { 4 | type: 'document', 5 | name: 'homepage', 6 | icon: FiHome, 7 | fields: [ 8 | { 9 | title: 'Options', 10 | name: 'options', 11 | type: 'object', 12 | options: { columns: 2 }, 13 | fields: [ 14 | { 15 | title: 'Option 1?', 16 | name: 'isOption', 17 | type: 'boolean', 18 | options: { layout: 'checkbox' }, 19 | initialValue: true, 20 | }, 21 | { 22 | title: 'Option 1?', 23 | name: 'isOption2', 24 | type: 'boolean', 25 | options: { layout: 'checkbox' }, 26 | initialValue: false, 27 | }, 28 | ], 29 | }, 30 | { 31 | name: 'title', 32 | type: 'string', 33 | title: 'Title', 34 | }, 35 | { 36 | name: 'projects', 37 | title: 'Projects', 38 | type: 'array', 39 | of: [ 40 | { 41 | title: 'Project', 42 | type: 'reference', 43 | to: [{ type: 'project' }], 44 | }, 45 | ], 46 | }, 47 | ], 48 | preview: { 49 | select: { 50 | title: 'title', 51 | }, 52 | prepare({ title }) { 53 | return { 54 | title, 55 | media: FiHome, 56 | }; 57 | }, 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /studio/schemas/documents/legal.js: -------------------------------------------------------------------------------- 1 | import { FiFile } from 'react-icons/fi'; 2 | import { slugify, slugValidation } from '../../utils/sanityHelper'; 3 | 4 | export default { 5 | type: 'document', 6 | name: 'legal', 7 | title: 'Legal', 8 | fields: [ 9 | { 10 | title: 'Title', 11 | name: 'title', 12 | type: 'string', 13 | }, 14 | { 15 | title: 'Slug', 16 | name: 'slug', 17 | type: 'slug', 18 | options: { source: 'title', slugify }, 19 | validation: slugValidation, 20 | }, 21 | { 22 | title: 'Text', 23 | name: 'text', 24 | type: 'blockContent', 25 | }, 26 | ], 27 | preview: { 28 | select: { 29 | title: 'title', 30 | }, 31 | prepare({ title }) { 32 | return { 33 | title, 34 | media: FiFile, 35 | }; 36 | }, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /studio/schemas/documents/page.js: -------------------------------------------------------------------------------- 1 | import { FiSidebar } from 'react-icons/fi'; 2 | import { MODULES } from '../../utils/sanityConstants'; 3 | 4 | export default { 5 | type: 'document', 6 | name: 'page', 7 | icon: FiSidebar, 8 | fields: [ 9 | { 10 | name: 'seo', 11 | title: 'SEO', 12 | type: 'seo', 13 | options: { collapsible: true, collapsed: true }, 14 | }, 15 | { 16 | name: 'title', 17 | type: 'string', 18 | }, 19 | { 20 | name: 'slug', 21 | type: 'slugField', 22 | }, 23 | { 24 | name: 'description', 25 | type: 'blockContent', 26 | }, 27 | { 28 | name: 'content', 29 | type: 'array', 30 | of: MODULES, 31 | }, 32 | ], 33 | preview: { 34 | select: { 35 | title: 'title', 36 | subtitle: 'description.0.children.0.text', 37 | }, 38 | prepare({ title, subtitle }) { 39 | return { 40 | title, 41 | subtitle, 42 | media: FiSidebar, 43 | }; 44 | }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /studio/schemas/documents/project.jsx: -------------------------------------------------------------------------------- 1 | import { orderRankField } from '@sanity/orderable-document-list'; 2 | import { FiSquare } from 'react-icons/fi'; 3 | import { generatePreviewMedia, mediaValidation, slugify, slugValidation } from '../../utils/sanityHelper'; 4 | 5 | export default { 6 | type: 'document', 7 | name: 'project', 8 | title: 'Project', 9 | icon: FiSquare, 10 | fields: [ 11 | orderRankField({ type: 'project', newItemPosition: 'before' }), 12 | { 13 | title: 'SEO', 14 | name: 'seo', 15 | type: 'seo', 16 | options: { collapsible: true, collapsed: true }, 17 | }, 18 | { 19 | name: 'isProjectHidden', 20 | title: 'Hide on Homepage?', 21 | description: 'The project can still be accessed via the URL', 22 | type: 'boolean', 23 | initialValue: false, 24 | }, 25 | { 26 | name: 'title', 27 | type: 'string', 28 | validation: (Rule) => Rule.required(), 29 | }, 30 | { 31 | name: 'slug', 32 | type: 'slug', 33 | options: { source: 'title', slugify }, 34 | validation: slugValidation, 35 | }, 36 | { 37 | name: 'date', 38 | type: 'date', 39 | }, 40 | { 41 | name: 'description', 42 | type: 'blockContent', 43 | }, 44 | { 45 | name: 'filter', 46 | type: 'array', 47 | of: [ 48 | { 49 | type: 'reference', 50 | to: [{ type: 'category' }], 51 | title: 'Category', 52 | // options: { 53 | // disableNew: true, 54 | // }, 55 | }, 56 | ], 57 | }, 58 | { 59 | title: 'Preview, Hero Media', 60 | name: 'media', 61 | type: 'media', 62 | validation: mediaValidation, 63 | }, 64 | { 65 | name: 'collaborator', 66 | type: 'object', 67 | options: { columns: 2 }, 68 | fields: [ 69 | { 70 | name: 'name', 71 | type: 'string', 72 | }, 73 | { 74 | name: 'website', 75 | type: 'url', 76 | }, 77 | ], 78 | }, 79 | { 80 | name: 'mediaGallery', 81 | type: 'mediaGallery', 82 | }, 83 | // { 84 | // name: 'content', 85 | // type: 'array', 86 | // of: ARTICLE_MODULES, 87 | // options: { 88 | // insertMenu: { 89 | // views: [{ name: 'grid', columns: 3, previewImageUrl: (schemaTypeName) => `/static/previews/article/${schemaTypeName}.png` }], 90 | // }, 91 | // }, 92 | // }, 93 | ], 94 | preview: { 95 | select: { 96 | title: 'title', 97 | type: 'media.type', 98 | image: 'media.image.asset', 99 | playbackId: 'media.video.asset.playbackId', 100 | }, 101 | prepare({ title, type, image, playbackId }) { 102 | return { 103 | title, 104 | media: generatePreviewMedia({ type, image, playbackId }), 105 | }; 106 | }, 107 | }, 108 | }; 109 | -------------------------------------------------------------------------------- /studio/schemas/documents/projectsOverview.js: -------------------------------------------------------------------------------- 1 | import { FiSidebar } from 'react-icons/fi'; 2 | import { slugify, slugValidation } from '../../utils/sanityHelper'; 3 | 4 | export default { 5 | name: 'projectsOverview', 6 | title: 'Projects Overview', 7 | type: 'document', 8 | icon: FiSidebar, 9 | fields: [ 10 | { 11 | name: 'title', 12 | type: 'string', 13 | title: 'Title', 14 | }, 15 | { 16 | title: 'Slug', 17 | name: 'slug', 18 | type: 'slug', 19 | options: { source: 'title', slugify }, 20 | validation: slugValidation, 21 | hidden: true, 22 | }, 23 | ], 24 | preview: { 25 | prepare() { 26 | return { 27 | title: 'Projects Overview', 28 | media: FiSidebar, 29 | }; 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /studio/schemas/index.js: -------------------------------------------------------------------------------- 1 | import homepage from './documents/homepage'; 2 | import project from './documents/project'; 3 | import projectsOverview from './documents/projectsOverview'; 4 | import category from './documents/category'; 5 | import seo from './objects/seo'; 6 | import mainImage from './objects/mainImage'; 7 | import media from './objects/media'; 8 | import blockContent from './objects/blockContent'; 9 | import page from './documents/page'; 10 | import legal from './documents/legal'; 11 | import global from './documents/global'; 12 | import socialLink from './objects/socialLink'; 13 | import mediaGallery from './objects/mediaGallery'; 14 | import textBlock from './modules/textBlock'; 15 | import cta from './objects/cta'; 16 | import guide from './documents/guide'; 17 | import mainVideo from './objects/mainVideo'; 18 | import slugField from './objects/slugField'; 19 | import gallery from './modules/gallery'; 20 | import redirect from './objects/redirect'; 21 | 22 | export const schemaTypes = [ 23 | guide, 24 | global, 25 | homepage, 26 | page, 27 | projectsOverview, 28 | project, 29 | legal, 30 | category, 31 | seo, 32 | mainImage, 33 | mainVideo, 34 | mediaGallery, 35 | media, 36 | blockContent, 37 | socialLink, 38 | textBlock, 39 | cta, 40 | slugField, 41 | gallery, 42 | redirect, 43 | ]; 44 | -------------------------------------------------------------------------------- /studio/schemas/modules/gallery.js: -------------------------------------------------------------------------------- 1 | import { FiGrid } from 'react-icons/fi'; 2 | import { generatePreviewMedia } from '../../utils/sanityHelper'; 3 | 4 | const moduleTitle = 'Gallery'; 5 | const icon = FiGrid; 6 | 7 | export default { 8 | title: moduleTitle, 9 | name: 'gallery', 10 | type: 'object', 11 | icon, 12 | fields: [ 13 | { 14 | name: 'mediaGallery', 15 | type: 'mediaGallery', 16 | validation: (Rule) => Rule.required().min(3), 17 | }, 18 | ], 19 | 20 | preview: { 21 | select: { 22 | assets: 'mediaGallery', 23 | type: 'mediaGallery.0._type', 24 | image: 'mediaGallery.0.asset', 25 | imageName: 'mediaGallery.0.asset.originalFilename', 26 | playbackId: 'mediaGallery.0.video.asset.playbackId', 27 | }, 28 | prepare({ type, assets, image, playbackId }) { 29 | return { 30 | title: `${Object.keys(assets).length || 0} Media Items`, 31 | subtitle: moduleTitle, 32 | media: generatePreviewMedia({ type, image, playbackId }), 33 | }; 34 | }, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /studio/schemas/modules/singleMedia.js: -------------------------------------------------------------------------------- 1 | import { FiImage } from 'react-icons/fi'; 2 | import { generatePreviewMedia, generatePreviewMediaTitle, mediaValidation } from '../../../utils/sanityHelper'; 3 | 4 | const moduleTitle = 'Single Media (with Video Player Option)'; 5 | const icon = FiImage; 6 | 7 | export default { 8 | title: moduleTitle, 9 | name: 'singleMedia', 10 | type: 'object', 11 | icon, 12 | fields: [ 13 | { 14 | name: 'layout', 15 | type: 'string', 16 | options: { 17 | list: [ 18 | { title: 'Small', value: 'small' }, 19 | { title: 'Fullscreen', value: 'fullscreen' }, 20 | ], 21 | layout: 'radio', 22 | direction: 'horizontal', 23 | }, 24 | initialValue: 'small', 25 | }, 26 | { 27 | name: 'media', 28 | type: 'media', 29 | validation: mediaValidation, 30 | }, 31 | { 32 | name: 'useVideoPlayer', 33 | title: 'Display as Video Player', 34 | description: 'When enabled, this will display video controls (play, pause, timeline, etc.)', 35 | type: 'boolean', 36 | hidden: ({ parent }) => !parent?.media?.type || parent.media.type !== 'video', 37 | initialValue: true, 38 | }, 39 | ], 40 | 41 | preview: { 42 | select: { 43 | title: 'image.alt', 44 | type: 'media.type', 45 | image: 'media.image.asset', 46 | imageName: 'media.image.asset.originalFilename', 47 | playbackId: 'media.video.asset.playbackId', 48 | }, 49 | prepare({ type, imageName, image, playbackId }) { 50 | return { 51 | title: generatePreviewMediaTitle({ type, image, imageName, playbackId }), 52 | subtitle: moduleTitle, 53 | media: generatePreviewMedia({ type, image, playbackId }), 54 | }; 55 | }, 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /studio/schemas/modules/textBlock.js: -------------------------------------------------------------------------------- 1 | import { FiAlignLeft } from 'react-icons/fi'; 2 | import { ctaValidation } from '../../utils/sanityHelper'; 3 | 4 | const moduleTitle = 'Text Block'; 5 | const icon = FiAlignLeft; 6 | 7 | export default { 8 | title: moduleTitle, 9 | name: 'textBlock', 10 | type: 'object', 11 | icon, 12 | fields: [ 13 | { 14 | name: 'text', 15 | type: 'blockContent', 16 | validation: (Rule) => Rule.required(), 17 | }, 18 | { 19 | name: 'cta', 20 | type: 'cta', 21 | validation: ctaValidation, 22 | }, 23 | ], 24 | preview: { 25 | select: { 26 | title: 'text.0.children[0].text', 27 | }, 28 | prepare({ title }) { 29 | return { 30 | title, 31 | subtitle: moduleTitle, 32 | media: icon, 33 | }; 34 | }, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /studio/schemas/objects/blockContent.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FiEdit2, FiGlobe, FiFile } from 'react-icons/fi'; 3 | import { PAGES } from '../../utils/sanityConstants'; 4 | 5 | const Highlight = (props) => {props.children}; 6 | 7 | export default { 8 | title: 'Block Content', 9 | name: 'blockContent', 10 | type: 'array', 11 | description: 'Create paragraphs with the key combination: shift + enter', 12 | of: [ 13 | { 14 | title: 'Block', 15 | type: 'block', 16 | styles: [], 17 | // styles: [ 18 | // {title: 'Regular', value: 'normal'}, 19 | // {title: 'Headline', value: 'h3'}, 20 | // ], 21 | lists: [{ title: 'Bullet', value: 'bullet' }], 22 | marks: { 23 | decorators: [ 24 | { title: 'Bold', value: 'strong' }, 25 | { title: 'Italic', value: 'em' }, 26 | { 27 | title: 'Highlight', 28 | value: 'highlight', 29 | icon: FiEdit2, 30 | component: Highlight, 31 | }, 32 | ], 33 | annotations: [ 34 | { 35 | name: 'externalLink', 36 | type: 'object', 37 | icon: FiGlobe, 38 | fields: [ 39 | { 40 | title: 'URL', 41 | name: 'href', 42 | type: 'url', 43 | description: 'To link email addresses use "mailto:" infront of the email, for phone numbers use "tel:"', 44 | validation: (Rule) => 45 | Rule.uri({ 46 | scheme: ['http', 'https', 'mailto', 'tel'], 47 | }).required(), 48 | }, 49 | ], 50 | }, 51 | { 52 | name: 'internalLink', 53 | type: 'object', 54 | icon: FiFile, 55 | fields: [ 56 | { 57 | name: 'page', 58 | type: 'reference', 59 | options: { disableNew: true }, 60 | validation: (Rule) => Rule.required(), 61 | to: PAGES, 62 | }, 63 | ], 64 | }, 65 | ], 66 | }, 67 | }, 68 | // defineArrayMember({ 69 | // type: 'image', 70 | // options: {hotspot: true}, 71 | // }), 72 | ], 73 | }; 74 | -------------------------------------------------------------------------------- /studio/schemas/objects/cta.js: -------------------------------------------------------------------------------- 1 | import { FiLink } from 'react-icons/fi'; 2 | import { PAGES } from '../../utils/sanityConstants'; 3 | 4 | // { 5 | // title: 'CTA', 6 | // name: 'cta', 7 | // type: 'cta', 8 | // validation: ctaValidation, 9 | // }, 10 | 11 | export default { 12 | title: 'CTA', 13 | name: 'cta', 14 | type: 'object', 15 | fields: [ 16 | { 17 | name: 'type', 18 | type: 'string', 19 | options: { 20 | list: [ 21 | { title: 'Internal Link', value: 'internalLink' }, 22 | { title: 'External Link', value: 'externalLink' }, 23 | { title: 'None', value: 'none' }, 24 | ], 25 | layout: 'radio', 26 | direction: 'horizontal', 27 | }, 28 | initialValue: 'internalLink', 29 | }, 30 | { 31 | name: 'title', 32 | type: 'string', 33 | hidden: ({ parent }) => !parent?.type || parent.type === 'none', 34 | }, 35 | { 36 | name: 'page', 37 | type: 'reference', 38 | to: PAGES, 39 | hidden: ({ parent }) => !parent?.type || parent.type === 'none' || parent.type !== 'internalLink', 40 | options: { disableNew: true }, 41 | }, 42 | { 43 | name: 'href', 44 | type: 'url', 45 | title: 'URL', 46 | description: 'For email addresses use "mailto:" in front of the email (e.g. mailto:example@email.com), for phone numbers use "tel:"', 47 | hidden: ({ parent }) => !parent?.type || parent.type === 'none' || parent.type !== 'externalLink', 48 | validation: (Rule) => 49 | Rule.uri({ 50 | scheme: ['http', 'https', 'mailto', 'tel'], 51 | }), 52 | }, 53 | ], 54 | preview: { 55 | select: { 56 | title: 'title', 57 | }, 58 | prepare({ title }) { 59 | return { 60 | title, 61 | media: FiLink, 62 | }; 63 | }, 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /studio/schemas/objects/mainImage.js: -------------------------------------------------------------------------------- 1 | import { FiImage } from 'react-icons/fi'; 2 | 3 | export default { 4 | title: 'Image', 5 | name: 'mainImage', 6 | type: 'image', 7 | icon: FiImage, 8 | options: { 9 | hotspot: true, 10 | }, 11 | fields: [ 12 | { 13 | title: 'Alternative Text', 14 | name: 'alt', 15 | type: 'string', 16 | description: 'Optional but recommended. A short description of the image that is important for accessibility and SEO.', 17 | }, 18 | ], 19 | preview: { 20 | select: { 21 | filename: 'asset.originalFilename', 22 | image: 'asset', 23 | }, 24 | prepare({ filename, image }) { 25 | return { 26 | title: filename, 27 | media: image, 28 | }; 29 | }, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /studio/schemas/objects/mainVideo.js: -------------------------------------------------------------------------------- 1 | import { FiVideo } from 'react-icons/fi'; 2 | import { generatePreviewMedia } from '../../utils/sanityHelper'; 3 | 4 | export default { 5 | title: 'Video', 6 | name: 'mainVideo', 7 | type: 'object', 8 | icon: FiVideo, 9 | fields: [ 10 | { 11 | name: 'video', 12 | type: 'mux.video', 13 | }, 14 | ], 15 | preview: { 16 | select: { 17 | filename: 'video.asset.filename', 18 | playbackId: 'video.asset.playbackId', 19 | }, 20 | prepare({ playbackId, filename }) { 21 | return { 22 | title: filename || 'Video', 23 | media: generatePreviewMedia({ type: 'video', playbackId }), 24 | }; 25 | }, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /studio/schemas/objects/media.jsx: -------------------------------------------------------------------------------- 1 | import { generatePreviewMedia, generatePreviewMediaTitle } from '../../utils/sanityHelper'; 2 | 3 | export default { 4 | name: 'media', 5 | type: 'object', 6 | initialValue: { type: 'image' }, 7 | fields: [ 8 | { 9 | name: 'type', 10 | type: 'string', 11 | options: { 12 | list: [ 13 | { title: 'Image', value: 'image' }, 14 | { title: 'Video', value: 'video' }, 15 | ], 16 | layout: 'radio', 17 | direction: 'horizontal', 18 | }, 19 | }, 20 | { 21 | name: 'image', 22 | type: 'mainImage', 23 | hidden: ({ parent }) => !parent?.type || parent.type !== 'image', 24 | options: { 25 | collapsed: false, 26 | hotspot: true, 27 | }, 28 | }, 29 | { 30 | name: 'video', 31 | type: 'mux.video', 32 | hidden: ({ parent }) => !parent?.type || parent.type !== 'video', 33 | options: { 34 | collapsed: false, 35 | }, 36 | }, 37 | ], 38 | preview: { 39 | select: { 40 | title: 'image.alt', 41 | type: 'type', 42 | image: 'image.asset', 43 | imageName: 'image.asset.originalFilename', 44 | playbackId: 'video.asset.playbackId', 45 | }, 46 | prepare({ type, imageName, image, playbackId }) { 47 | return { 48 | title: generatePreviewMediaTitle({ type, image, imageName, playbackId }), 49 | media: generatePreviewMedia({ type, image, playbackId }), 50 | }; 51 | }, 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /studio/schemas/objects/mediaGallery.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'mediaGallery', 3 | type: 'array', 4 | description: 5 | 'Drag & drop multiple images from the finder/file explorer into the field below. Videos need to be uploaded manually with "add item..."', 6 | of: [{ type: 'mainImage' }, { type: 'mainVideo' }], 7 | options: { layout: 'grid' }, 8 | }; 9 | -------------------------------------------------------------------------------- /studio/schemas/objects/mediaWithMobile.jsx: -------------------------------------------------------------------------------- 1 | import { generatePreviewMedia, generatePreviewMediaTitle } from '../../utils/sanityHelper'; 2 | 3 | export default { 4 | name: 'mediaWithMobile', 5 | type: 'object', 6 | fields: [ 7 | { 8 | name: 'media', 9 | type: 'media', 10 | options: { collapsible: true, collapsed: false }, 11 | }, 12 | { 13 | name: 'mediaMobile', 14 | type: 'media', 15 | options: { collapsible: true, collapsed: true }, 16 | }, 17 | ], 18 | preview: { 19 | select: { 20 | title: 'image.alt', 21 | type: 'type', 22 | image: 'image.asset', 23 | imageName: 'image.asset.originalFilename', 24 | playbackId: 'video.asset.playbackId', 25 | }, 26 | prepare({ type, imageName, image, playbackId }) { 27 | return { 28 | title: generatePreviewMediaTitle({ type, image, imageName, playbackId }), 29 | media: generatePreviewMedia({ type, image, playbackId }), 30 | }; 31 | }, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /studio/schemas/objects/redirect.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'redirect', 3 | type: 'document', 4 | title: 'Redirect', 5 | fields: [ 6 | { 7 | name: 'from', 8 | title: 'From Path', 9 | type: 'string', 10 | description: 'The path to redirect from (e.g., /old-page)', 11 | validation: (Rule) => Rule.required(), 12 | }, 13 | { 14 | name: 'to', 15 | title: 'To Path', 16 | type: 'string', 17 | description: 'The path to redirect to (e.g., /new-page)', 18 | validation: (Rule) => Rule.required(), 19 | }, 20 | { 21 | name: 'statusCode', 22 | title: 'Status Code', 23 | type: 'number', 24 | options: { 25 | list: [ 26 | { title: 'Permanent (301)', value: 301 }, 27 | { title: 'Temporary (302)', value: 302 }, 28 | ], 29 | }, 30 | initialValue: 301, 31 | }, 32 | ], 33 | }; 34 | -------------------------------------------------------------------------------- /studio/schemas/objects/seo.js: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'SEO', 3 | name: 'seo', 4 | type: 'object', 5 | fields: [ 6 | { 7 | title: 'Hide this Page from Search Engine Indexing?', 8 | name: 'notIndexed', 9 | type: 'boolean', 10 | initialValue: false, 11 | }, 12 | { 13 | name: 'metaTitle', 14 | type: 'string', 15 | }, 16 | { 17 | name: 'metaDescription', 18 | type: 'text', 19 | validation: (Rule) => Rule.max(155).warning('Should be under 155 characters'), 20 | }, 21 | { 22 | title: 'Open Graph Image', 23 | name: 'ogImage', 24 | type: 'image', 25 | }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /studio/schemas/objects/slugField.js: -------------------------------------------------------------------------------- 1 | import { CustomSlugInput } from '../../components/CustomSlugInput'; 2 | import { slugify, slugValidation } from '../../utils/sanityHelper'; 3 | 4 | export default { 5 | name: 'slugField', 6 | type: 'slug', 7 | description: 'Is a part of the URL that serves as an unique identifier of the page.', 8 | options: { source: 'title', slugify }, 9 | validation: slugValidation, 10 | components: { 11 | input: CustomSlugInput, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /studio/schemas/objects/socialLink.js: -------------------------------------------------------------------------------- 1 | import { FiLink2 } from 'react-icons/fi'; 2 | 3 | export default { 4 | name: 'socialLink', 5 | title: 'Link', 6 | type: 'object', 7 | options: { columns: 2 }, 8 | fields: [ 9 | { 10 | name: 'title', 11 | type: 'string', 12 | validation: (Rule) => Rule.required(), 13 | }, 14 | { 15 | title: 'URL', 16 | name: 'url', 17 | type: 'string', 18 | validation: (Rule) => Rule.required(), 19 | }, 20 | ], 21 | preview: { 22 | select: { 23 | title: 'title', 24 | }, 25 | prepare({ title }) { 26 | return { 27 | title, 28 | media: FiLink2, 29 | }; 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /studio/schemas/references/author.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'author', 3 | type: 'document', 4 | fields: [ 5 | { 6 | name: 'name', 7 | type: 'string', 8 | validation: (Rule) => Rule.required(), 9 | }, 10 | { 11 | name: 'jobTitle', 12 | title: 'Job title', 13 | type: 'string', 14 | }, 15 | { 16 | title: 'Portrait', 17 | name: 'media', 18 | type: 'media', 19 | }, 20 | ], 21 | preview: { 22 | select: { 23 | title: 'name', 24 | }, 25 | prepare({ title }) { 26 | return { 27 | title, 28 | }; 29 | }, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /studio/schemas/references/category.js: -------------------------------------------------------------------------------- 1 | import { FiMinus } from 'react-icons/fi'; 2 | 3 | export default { 4 | name: 'category', 5 | type: 'document', 6 | icon: FiMinus, 7 | fields: [ 8 | { 9 | name: 'title', 10 | type: 'string', 11 | validation: (Rule) => Rule.required(), 12 | }, 13 | ], 14 | preview: { 15 | select: { 16 | title: 'title', 17 | }, 18 | prepare: ({ title }) => ({ 19 | title, 20 | }), 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /studio/schemas/references/client.js: -------------------------------------------------------------------------------- 1 | import { FiMinus } from 'react-icons/fi'; 2 | 3 | export default { 4 | name: 'client', 5 | type: 'document', 6 | icon: FiMinus, 7 | fields: [ 8 | { 9 | name: 'name', 10 | type: 'string', 11 | validation: (Rule) => Rule.required(), 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /studio/structure.js: -------------------------------------------------------------------------------- 1 | import { orderableDocumentListDeskItem } from '@sanity/orderable-document-list'; 2 | import { FiCircle, FiFileText, FiSettings, FiSidebar, FiSquare, FiHome, FiInfo, FiLink } from 'react-icons/fi'; 3 | 4 | export const structure = (S, context) => 5 | S.list() 6 | .title('Content') 7 | .items([ 8 | S.listItem().title('Guide').icon(FiInfo).child(S.editor().schemaType('guide').documentId('guide')), 9 | S.listItem().title('Global Settings').icon(FiSettings).child(S.editor().schemaType('global').documentId('global')), 10 | S.divider(), 11 | S.listItem().title('Homepage').icon(FiHome).child(S.editor().schemaType('homepage').documentId('homepage')), 12 | S.listItem() 13 | .title('Work') 14 | .icon(FiCircle) 15 | .child( 16 | S.list() 17 | .title('Work') 18 | .items([ 19 | S.listItem().title('Projects Overview').icon(FiSidebar).child(S.editor().schemaType('projectsOverview').documentId('projectsOverview')), 20 | orderableDocumentListDeskItem({ 21 | type: 'project', 22 | title: 'Projects', 23 | icon: FiSquare, 24 | S, 25 | context, 26 | }), 27 | ]), 28 | ), 29 | S.listItem().title('Pages').icon(FiSidebar).child(S.documentTypeList('page').title('Pages')), 30 | S.listItem().title('Legal').icon(FiFileText).child(S.editor().schemaType('legal').documentId('legal')), 31 | S.divider(), 32 | S.listItem().title('Redirects').icon(FiLink).child(S.documentTypeList('redirect').title('Redirects')), 33 | ]); 34 | -------------------------------------------------------------------------------- /studio/utils/sanityConstants.js: -------------------------------------------------------------------------------- 1 | export const PAGES = [{ type: 'homepage' }, { type: 'projectsOverview' }, { type: 'page' }, { type: 'project' }]; 2 | export const MODULES = [{ type: 'textBlock' }, { type: 'gallery' }]; 3 | -------------------------------------------------------------------------------- /studio/utils/sanityHelper.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const slugify = (slug) => { 4 | return slug 5 | .toString() 6 | .normalize('NFD') 7 | .replace(/[\u0300-\u036f]/g, '') 8 | .toLowerCase() 9 | .trim() 10 | .replace(/\s+/g, '-') 11 | .replace(/[^\w-]+/g, '') 12 | .replace(/--+/g, '-') 13 | .replace(/ä/g, 'ae') 14 | .replace(/ö/g, 'oe') 15 | .replace(/ü/g, 'ue') 16 | .replace(/ß/g, 'ss') 17 | .slice(0, 200); 18 | }; 19 | 20 | export const slugValidation = (Rule) => 21 | Rule.custom((slug) => { 22 | if (typeof slug === 'undefined') return true; 23 | 24 | const slugRule = new RegExp('^[a-z0-9]+(?:-[a-z0-9]+)*$'); 25 | 26 | return slugRule.test(slug.current) ? true : 'Please only use lowercase letters, numbers or single hyphens.'; 27 | }).required(); 28 | 29 | export const mediaValidation = (Rule) => 30 | Rule.custom((props) => { 31 | if ((props.type === 'image' && props.image) || (props.type === 'video' && props.video)) return true; 32 | return 'Image or Video required'; 33 | }); 34 | 35 | export const generatePreviewMediaTitle = (options) => { 36 | if ((options.type === 'image' || options.type === 'mainImage') && options.image) return options.imageName; 37 | if ((options.type === 'video' || options.type === 'mainVideo') && options.playbackId) return 'Video'; 38 | 39 | return '[Empty]'; 40 | }; 41 | 42 | export const generatePreviewMedia = (options) => { 43 | if ((options.type === 'image' || options.type === 'mainImage') && options.image) return options.image; 44 | if ((options.type === 'video' || options.type === 'mainVideo') && options.playbackId) 45 | return ( 46 |
47 | Video Preview 48 |
49 | ); 50 | }; 51 | 52 | export const ctaValidation = (Rule) => 53 | Rule.custom((props) => { 54 | if ( 55 | props.type === 'none' || 56 | (props.type === 'internalLink' && props.title && props.page) || 57 | (props.type === 'externalLink' && props.title && props.href) 58 | ) 59 | return true; 60 | 61 | return 'Internal Link or External Link required'; 62 | }); 63 | -------------------------------------------------------------------------------- /web/.env.example: -------------------------------------------------------------------------------- 1 | SANITY_PROJECT_ID=xyz -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@nuxt/eslint-config'], 4 | rules: { 5 | 'no-console': 1, 6 | 'no-undef': 1, 7 | 'vue/multi-word-component-names': 0, 8 | 'vue/require-default-prop': 0, 9 | 'vue/require-explicit-emits': 0, 10 | 'vue/no-unused-vars': 1, 11 | 'vue/singleline-html-element-content-newline': 0, 12 | 'vue/max-attributes-per-line': 0, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "printWidth": 150 5 | } 6 | -------------------------------------------------------------------------------- /web/assets/styles/fallback.css: -------------------------------------------------------------------------------- 1 | @supports not (height: 100svh) { 2 | .h-svh { 3 | height: 100vh; 4 | } 5 | 6 | .h-screen { 7 | height: 100vh; 8 | } 9 | } 10 | 11 | @supports not (inset: 0) { 12 | .inset-0 { 13 | top: 0; 14 | left: 0; 15 | width: 100%; 16 | height: 100%; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /web/assets/styles/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: GT; 3 | src: url('/fonts/GT.woff2') format('woff2'); 4 | } 5 | -------------------------------------------------------------------------------- /web/assets/styles/main.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | /* TODO */ 4 | .page-enter-active, 5 | .page-leave-active { 6 | transition: opacity 0.4s; 7 | } 8 | .page-enter-from, 9 | .page-leave-to { 10 | opacity: 0; 11 | } 12 | 13 | body { 14 | @apply font-gt text-14 selection:bg-gray leading-tight tracking-tight; 15 | } 16 | 17 | @theme { 18 | --color-*: initial; 19 | --color-preview-red: hsl(0, 80%, 40%); 20 | --color-black: hsl(0, 0%, 0%); 21 | --color-white: hsl(0, 0%, 100%); 22 | --color-gray: hsl(0, 0%, 36%); 23 | 24 | --font-gt: GT, sans-serif; 25 | /* --font-georgia: Georgia, serif; 26 | --font-courier: 'Courier New', mono; */ 27 | 28 | --text-*: initial; 29 | --text-14: 1.4rem; 30 | --text-large: clamp(2.5rem, 7vw + 0.5rem, 10rem); 31 | 32 | /* power1 */ 33 | --ease-in-quad: cubic-bezier(0.11, 0, 0.5, 0); 34 | --ease-out-quad: cubic-bezier(0.5, 1, 0.89, 1); 35 | --ease-in-out-quad: cubic-bezier(0.45, 0, 0.55, 1); 36 | /* power2 */ 37 | --ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19); 38 | --ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1); 39 | --ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1); 40 | /* power3 */ 41 | --ease-in-quart: cubic-bezier(0.5, 0, 0.75, 0); 42 | --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); 43 | --ease-in-out-quart: cubic-bezier(0.76, 0, 0.24, 1); 44 | /* power4 */ 45 | --ease-in-quint: cubic-bezier(0.64, 0, 0.78, 0); 46 | --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); 47 | --ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1); 48 | /* expo */ 49 | --ease-in-expo: cubic-bezier(0.7, 0, 0.84, 0); 50 | --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); 51 | --ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1); 52 | } 53 | 54 | @utility scrollbar-hidden { 55 | -ms-overflow-style: none; 56 | scrollbar-width: none; 57 | 58 | &::-webkit-scrollbar { 59 | display: none; 60 | } 61 | } 62 | 63 | @utility breakword { 64 | word-break: break-word; 65 | hyphens: auto; 66 | } 67 | 68 | @utility svg-full { 69 | svg { 70 | width: 100%; 71 | height: 100%; 72 | } 73 | } 74 | 75 | @utility grid-stacked { 76 | grid-area: 1 / 1; 77 | } 78 | 79 | @utility gpu { 80 | transform: translate3d(); 81 | } 82 | 83 | @layer components { 84 | /** 85 | * Components 86 | */ 87 | .card { 88 | background-color: var(--color-white); 89 | border-radius: var(--rounded-lg); 90 | padding: var(--spacing-6); 91 | box-shadow: var(--shadow-xl); 92 | } 93 | 94 | /** 95 | * Spacing 96 | */ 97 | .spacing-x { 98 | @apply px-4 lg:px-8; 99 | } 100 | 101 | /** 102 | * Typography 103 | */ 104 | .hl { 105 | @apply text-balance; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /web/assets/styles/normalize.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @layer base { 4 | :root { 5 | font-size: 62.5%; 6 | } 7 | 8 | *, 9 | *:before, 10 | *:after { 11 | margin: 0; 12 | padding: 0; 13 | border: 0; 14 | box-sizing: border-box; 15 | } 16 | 17 | * { 18 | font: inherit; 19 | } 20 | 21 | html, 22 | body { 23 | height: 100%; 24 | } 25 | 26 | body { 27 | -webkit-font-smoothing: antialiased; 28 | -moz-osx-font-smoothing: grayscale; 29 | } 30 | 31 | #root { 32 | isolation: isolate; 33 | } 34 | 35 | ul, 36 | ol { 37 | list-style: none; 38 | } 39 | 40 | p, 41 | h1, 42 | h2, 43 | h3, 44 | h4, 45 | h5, 46 | h6 { 47 | overflow-wrap: break-word; 48 | } 49 | 50 | img, 51 | picture, 52 | video, 53 | canvas, 54 | svg { 55 | display: block; 56 | max-width: 100%; 57 | } 58 | 59 | button:not(:disabled), 60 | [role='button']:not(:disabled) { 61 | cursor: pointer; 62 | } 63 | 64 | input { 65 | border-radius: 0; 66 | background: transparent; 67 | } 68 | 69 | textarea { 70 | resize: none; 71 | background: transparent; 72 | } 73 | 74 | select:focus-visible, 75 | input:focus-visible, 76 | button:focus-visible, 77 | a:focus-visible { 78 | outline: 2px solid black; 79 | border-radius: 3px; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /web/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /web/components/Header/Header.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | -------------------------------------------------------------------------------- /web/components/Header/NavBtn.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 56 | -------------------------------------------------------------------------------- /web/components/Header/NavDesktop.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 20 | -------------------------------------------------------------------------------- /web/components/Header/NavMobile.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 35 | -------------------------------------------------------------------------------- /web/components/Icons/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/annaerdelen/baustelle/ae2cbb48615d0b246a0a65dfa2c3a149c91250c8/web/components/Icons/.gitkeep -------------------------------------------------------------------------------- /web/components/Modules.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /web/components/Modules/gallery.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /web/components/Modules/textBlock.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /web/components/Partials/BlockContent.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 31 | 32 | 59 | -------------------------------------------------------------------------------- /web/components/Partials/BlockContentLink.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | -------------------------------------------------------------------------------- /web/components/Partials/Cta.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /web/components/Partials/Img.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 70 | 71 | 86 | -------------------------------------------------------------------------------- /web/components/Partials/Media.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 23 | -------------------------------------------------------------------------------- /web/components/Partials/Video.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 88 | 89 | 107 | -------------------------------------------------------------------------------- /web/components/PreviewBanner.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 | -------------------------------------------------------------------------------- /web/components/ProjectPreview.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /web/composables/states.js: -------------------------------------------------------------------------------- 1 | export const useShowPreviewBanner = () => useState('showPreviewBanner', () => false); 2 | export const useCurrentX = () => useState('currentX', () => 0); 3 | export const useIsMenuOpen = () => useState('isMenuOpen', () => false); 4 | 5 | export const useMenuState = () => { 6 | const isMenuOpen = useState('isMenuOpen', () => false); 7 | 8 | return { 9 | isMenuOpen, 10 | openMenu: () => (isMenuOpen.value = true), 11 | closeMenu: () => (isMenuOpen.value = false), 12 | toggleMenu: () => (isMenuOpen.value = !isMenuOpen), 13 | }; 14 | }; 15 | 16 | export const useIndexStore = () => { 17 | const activeItem = useState('activeItem', () => 0); 18 | return { activeItem }; 19 | }; 20 | -------------------------------------------------------------------------------- /web/composables/use-credits.js: -------------------------------------------------------------------------------- 1 | export default function () { 2 | onMounted(() => { 3 | const contentStyles = [].join(';'); 4 | const linkStyles = ['text-decoration: underline'].join(';'); 5 | 6 | console.groupCollapsed('Website Credits ✌️'); 7 | //TODO console.log('%cDesign: Designer %chttps//www.website.com', contentStyles, linkStyles); 8 | console.log('%cDevelopment: Anna Erdelen %chttps://www.erdelen.com', contentStyles, linkStyles); 9 | console.groupEnd(); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /web/composables/use-detect-browser.js: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const isChrome = ref(undefined); 3 | const isExplorer = ref(undefined); 4 | const isFirefox = ref(undefined); 5 | const isBrave = ref(undefined); 6 | const isSafari = ref(undefined); 7 | 8 | onMounted(() => { 9 | isChrome.value = navigator.userAgent.includes('Chrome'); 10 | isExplorer.value = navigator.userAgent.includes('MSIE'); 11 | isFirefox.value = navigator.userAgent.includes('Firefox'); 12 | isBrave.value = navigator.brave !== undefined; 13 | isSafari.value = !isBrave.value && !isChrome.value && navigator.userAgent.includes('Safari'); 14 | }); 15 | 16 | return { isChrome, isExplorer, isFirefox, isSafari, isBrave }; 17 | } 18 | -------------------------------------------------------------------------------- /web/composables/use-in-view.js: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap'; 2 | 3 | import { ScrollTrigger } from 'gsap/ScrollTrigger'; 4 | 5 | gsap.registerPlugin(ScrollTrigger); 6 | 7 | export default function (getTrigger) { 8 | const isInView = ref(false); 9 | let scrollTrigger; 10 | 11 | onMounted(() => { 12 | const trigger = getTrigger(); 13 | 14 | if (!trigger) return; 15 | 16 | gsap.delayedCall(1, () => { 17 | scrollTrigger = ScrollTrigger.create({ 18 | trigger, 19 | start: 'top bottom', 20 | onEnter: () => (isInView.value = true), 21 | // markers: true, 22 | // once: true, 23 | }); 24 | }); 25 | }); 26 | 27 | onBeforeUnmount(() => { 28 | if (scrollTrigger) scrollTrigger.kill(); 29 | }); 30 | 31 | return { isInView }; 32 | } 33 | 34 | // USAGE 35 | // const { isInView } = useInView(() => video.value); 36 | -------------------------------------------------------------------------------- /web/composables/use-key.js: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const key = ref(undefined); 3 | 4 | function onKey({ keyCode }) { 5 | if (keyCode === 27) { 6 | key.value = 'escape'; 7 | } else if (keyCode === 37) { 8 | key.value = 'left'; 9 | } else if (keyCode === 38) { 10 | key.value = 'up'; 11 | } else if (keyCode === 39) { 12 | key.value = 'right'; 13 | } else if (keyCode === 40) { 14 | key.value = 'down'; 15 | } else { 16 | key.value = undefined; 17 | } 18 | } 19 | 20 | onMounted(() => { 21 | window.addEventListener('keydown', onKey); 22 | window.addEventListener('keyup', () => (key.value = undefined)); 23 | }); 24 | 25 | onBeforeUnmount(() => window.removeEventListener('keydown', onKey)); 26 | 27 | return { key }; 28 | } 29 | -------------------------------------------------------------------------------- /web/composables/use-sanity-data.js: -------------------------------------------------------------------------------- 1 | export default async function ({ query, slug = '' }) { 2 | const route = useRoute(); 3 | const { previewMode, previewData, startPreviewMode, stopPreviewMode } = useSanityPreview(); 4 | 5 | onMounted(() => { 6 | if (route.query.preview) { 7 | console.log('🏃‍♀️ Start Preview Mode'); 8 | startPreviewMode(query, slug); 9 | } 10 | }); 11 | 12 | onUnmounted(() => { 13 | if (previewMode.value) { 14 | console.log('🛑 Stop Preview Mode'); 15 | stopPreviewMode(); 16 | } 17 | }); 18 | 19 | const { data } = await useSanityQuery(query, { slug }); 20 | const computedData = computed(() => (previewMode.value ? previewData.value : data.value)); 21 | 22 | return { data: computedData }; 23 | } 24 | -------------------------------------------------------------------------------- /web/composables/use-sanity-preview.js: -------------------------------------------------------------------------------- 1 | export default function () { 2 | const showPreviewBanner = useShowPreviewBanner(); 3 | const sanity = useSanity(); 4 | const previewMode = ref(false); 5 | const previewData = ref(null); 6 | 7 | const startPreviewMode = async (query, slug) => { 8 | showPreviewBanner.value = true; 9 | previewMode.value = true; 10 | 11 | // modify the sanity client to use preview mode 12 | const previewClient = sanity.client.withConfig({ useCdn: false, withCredentials: true, perspective: 'previewDrafts' }); 13 | 14 | try { 15 | const data = await previewClient.fetch(query, { slug }); 16 | previewData.value = data; 17 | } catch (err) { 18 | console.log('preview error:', err.message); 19 | } 20 | }; 21 | 22 | const stopPreviewMode = () => { 23 | showPreviewBanner.value = false; 24 | useRouter()?.push({}); 25 | 26 | previewMode.value = false; 27 | previewData.value = null; 28 | 29 | setTimeout(() => { 30 | window.location.reload(); 31 | }, 200); 32 | }; 33 | 34 | return { 35 | previewMode, 36 | previewData, 37 | startPreviewMode, 38 | stopPreviewMode, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /web/composables/use-seo.js: -------------------------------------------------------------------------------- 1 | export default function ({ siteTitle, title, seo, hidden = false }) { 2 | const pageTitle = seo?.metaTitle || title; 3 | const description = seo?.metaDescription; 4 | const image = seo?.ogImage; 5 | 6 | useHead({ 7 | title: pageTitle ? `${pageTitle} • ${siteTitle}` : siteTitle, 8 | meta: [ 9 | hidden || seo?.notIndexed ? { name: 'robots', content: 'noindex, nofollow' } : {}, 10 | { 11 | name: 'title', 12 | content: pageTitle || siteTitle, 13 | }, 14 | { 15 | property: 'og:title', 16 | content: pageTitle || siteTitle, 17 | }, 18 | description && { 19 | name: 'description', 20 | content: description, 21 | }, 22 | description && { 23 | property: 'og:description', 24 | content: description, 25 | }, 26 | image && { 27 | property: 'og:image', 28 | content: image, 29 | }, 30 | ], 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /web/composables/use-size.js: -------------------------------------------------------------------------------- 1 | import { useDebounceFn } from '@vueuse/core'; 2 | import { BREAKPOINTS } from '@/utils/constants'; 3 | 4 | export default function () { 5 | const width = ref(0); 6 | const height = ref(0); 7 | const isMobile = ref(false); 8 | const isTablet = ref(false); 9 | 10 | function onResize() { 11 | width.value = window.innerWidth; 12 | height.value = window.innerHeight; 13 | 14 | isTablet.value = width.value < BREAKPOINTS.lg; 15 | isMobile.value = width.value < BREAKPOINTS.md; 16 | } 17 | 18 | const debouncedResize = useDebounceFn(onResize, 300); 19 | 20 | onMounted(() => { 21 | onResize(); 22 | 23 | window.addEventListener('resize', debouncedResize); 24 | }); 25 | 26 | onBeforeUnmount(() => { 27 | window.removeEventListener('resize', debouncedResize); 28 | }); 29 | 30 | return { width, height, isMobile, isTablet }; 31 | } 32 | -------------------------------------------------------------------------------- /web/error.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 23 | -------------------------------------------------------------------------------- /web/functions/template.js: -------------------------------------------------------------------------------- 1 | export const handler = async (event, context) => { 2 | // statusCode and body are necessary 3 | // http status codes: 200 OK (success), 400 Bad Request, 404 Not found, 500 Internal Server Error (general server error) 4 | // body needs to be a string JSON.stringify(data) 5 | return { 6 | statusCode: 200, 7 | body: 'Netlify Functions Example', 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /web/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 42 | -------------------------------------------------------------------------------- /web/layouts/empty.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /web/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base ="/web" 3 | command = "pnpm run generate" 4 | publish = "dist" 5 | functions = "functions" 6 | 7 | [functions] 8 | node_bundler = "esbuild" 9 | 10 | [dev] 11 | base ="/web" 12 | command = "pnpm dev" -------------------------------------------------------------------------------- /web/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite'; 2 | // import { createClient } from '@sanity/client'; 3 | // import fs from 'fs'; 4 | // import path from 'path'; 5 | 6 | // const client = createClient({ 7 | // projectId: process.env.SANITY_PROJECT_ID, 8 | // dataset: 'production', 9 | // apiVersion: '2024-10-20', 10 | // useCdn: false, // Set to true for caching in production 11 | // }); 12 | 13 | export default defineNuxtConfig({ 14 | // hooks: { 15 | // 'nitro:build:before': async () => { 16 | // console.log('Generating Netlify redirects...'); 17 | 18 | // const redirects = await client.fetch('*[_type == "redirect"]'); 19 | // const redirectsContent = redirects.map(({ from, to, statusCode }) => `${from} ${to} ${statusCode || 301}`).join('\n'); 20 | 21 | // const redirectsPath = path.resolve('public/_redirects'); 22 | // fs.writeFileSync(redirectsPath, redirectsContent); 23 | 24 | // console.log('✅ Netlify redirects file created.'); 25 | // }, 26 | // }, 27 | 28 | // routeRules: { 29 | // '/about': { redirect: '/' }, 30 | // '/work/**': { redirect: '/projects' }, 31 | // }, 32 | 33 | ssr: true, 34 | 35 | nitro: { 36 | preset: 'netlify-static', 37 | }, 38 | 39 | vite: { 40 | plugins: [tailwindcss()], 41 | }, 42 | 43 | app: { 44 | //TODO 45 | head: { 46 | // script: [ 47 | // { defer: true, 'data-domain': 'domain.de', src: 'https://plausible.io/js/plausible.js' } 48 | // ], 49 | // link: [ 50 | // { 51 | // rel: 'preload', 52 | // as: 'font', 53 | // type: 'font/woff2', 54 | // crossorigin: true, 55 | // href: '/fonts/PFDasGroteskMonoPro-Light.woff2', 56 | // }, 57 | // ], 58 | noscript: [{ children: 'Please enable JavaScript to view this website.' }], 59 | }, 60 | pageTransition: { name: 'page', mode: 'out-in' }, 61 | }, 62 | 63 | modules: ['@nuxtjs/sanity', '@nuxt/image'], 64 | 65 | css: ['@/assets/styles/fonts.css', '@/assets/styles/main.css', '@/assets/styles/normalize.css', '@/assets/styles/fallback.css'], 66 | 67 | imports: { dirs: ['./stores'] }, 68 | 69 | components: [{ path: '~/components', pathPrefix: false }], 70 | 71 | sanity: { 72 | projectId: process.env.SANITY_PROJECT_ID, 73 | dataset: 'production', 74 | apiVersion: '2024-10-20', 75 | useCdn: false, 76 | }, 77 | 78 | image: { 79 | providers: { 80 | mux: { 81 | name: 'mux', 82 | provider: '~/providers/mux-provider', 83 | }, 84 | }, 85 | sanity: { 86 | projectId: process.env.SANITY_PROJECT_ID, 87 | }, 88 | screens: { 89 | 320: 320, 90 | 640: 640, 91 | 768: 768, 92 | 1024: 1024, 93 | 1280: 1280, 94 | 1536: 1536, 95 | 1920: 1920, 96 | 2560: 2560, 97 | 3200: 3200, 98 | 3201: 3201, 99 | }, 100 | }, 101 | 102 | //TODO 103 | // router: { 104 | // options: { 105 | // scrollBehaviorType: 'smooth', 106 | // }, 107 | // }, 108 | }); 109 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxt dev --host", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "postinstall": "nuxt prepare" 10 | }, 11 | "devDependencies": { 12 | "@nuxt/eslint-config": "^0.7.4", 13 | "eslint": "^8.57.0", 14 | "eslint-config-prettier": "^9.1.0", 15 | "eslint-plugin-vue": "^9.22.0", 16 | "nuxt": "^3.15.0", 17 | "prettier": "^3.2.5" 18 | }, 19 | "dependencies": { 20 | "@nuxt/image": "^1.8.1", 21 | "@nuxtjs/sanity": "^1.13.3", 22 | "@portabletext/vue": "^1.0.6", 23 | "@sanity/client": "^7.1.0", 24 | "@sanity/image-url": "^1.1.0", 25 | "@tailwindcss/vite": "^4.1.5", 26 | "@vueuse/core": "^13.1.0", 27 | "gsap": "^3.13.0", 28 | "hls.js": "^1.5.18", 29 | "tailwindcss": "^4.1.5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /web/pages/[page].vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 29 | -------------------------------------------------------------------------------- /web/pages/functions.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 46 | -------------------------------------------------------------------------------- /web/pages/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 38 | -------------------------------------------------------------------------------- /web/pages/work/[project].vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 59 | -------------------------------------------------------------------------------- /web/pages/work/index.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 41 | -------------------------------------------------------------------------------- /web/plugins/gsap.js: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap'; 2 | import { ScrollTrigger } from 'gsap/ScrollTrigger'; 3 | // import { SplitText } from 'gsap/SplitText'; 4 | 5 | //TODO remove when not needed 6 | export default defineNuxtPlugin(() => { 7 | gsap.registerPlugin(ScrollTrigger); 8 | }); 9 | -------------------------------------------------------------------------------- /web/plugins/sanity-image-builder.js: -------------------------------------------------------------------------------- 1 | import imageUrlBuilder from '@sanity/image-url'; 2 | 3 | export default defineNuxtPlugin(() => { 4 | const builder = imageUrlBuilder(useSanity().config); 5 | 6 | function urlFor(source) { 7 | return builder.image(source).auto('format'); 8 | } 9 | 10 | return { 11 | provide: { urlFor }, 12 | }; 13 | }); 14 | -------------------------------------------------------------------------------- /web/providers/mux-provider.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderGetImage } from '@nuxt/image'; 2 | 3 | export const getImage: ProviderGetImage = (src, { modifiers = {} } = {}) => { 4 | // Extract playbackId from src if passed as full URL 5 | const playbackId = src; 6 | 7 | // Build Mux URL with modifiers 8 | const width = modifiers.width || '5'; 9 | const time = modifiers.time || '0'; 10 | 11 | return { 12 | url: `https://image.mux.com${playbackId}/thumbnail.webp?time=${time}&width=${width}`, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /web/public/_redirects: -------------------------------------------------------------------------------- 1 | /about / 302 2 | /work/green /work/red 301 -------------------------------------------------------------------------------- /web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/annaerdelen/baustelle/ae2cbb48615d0b246a0a65dfa2c3a149c91250c8/web/public/favicon.png -------------------------------------------------------------------------------- /web/public/fonts/GT.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/annaerdelen/baustelle/ae2cbb48615d0b246a0a65dfa2c3a149c91250c8/web/public/fonts/GT.woff2 -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /web/utils/constants.js: -------------------------------------------------------------------------------- 1 | export const BREAKPOINTS = { 2 | xs: 450, 3 | sm: 640, 4 | md: 768, 5 | lg: 1024, 6 | xl: 1280, 7 | '2xl': 1440, 8 | '3xl': 1920, 9 | }; 10 | -------------------------------------------------------------------------------- /web/utils/helper.js: -------------------------------------------------------------------------------- 1 | import { ScrollTrigger } from 'gsap/ScrollTrigger'; 2 | 3 | export function select(selector, container = document) { 4 | return container.querySelector(selector); 5 | } 6 | 7 | export function selectAll(selector, container = document) { 8 | return container.querySelectorAll(selector); 9 | } 10 | 11 | export const removeDuplicates = (array) => { 12 | const uniqueSet = new Set(array); 13 | return Array.from(uniqueSet); 14 | }; 15 | 16 | export const slugToString = (slug) => { 17 | const slugArray = slug.split('-'); 18 | const stringArray = slugArray.map((word) => word.charAt(0).toUpperCase() + word.slice(1)); 19 | return stringArray.join(' '); 20 | }; 21 | 22 | export const refreshScrollTriggers = () => { 23 | ScrollTrigger.getAll().forEach((st) => st.refresh()); 24 | }; 25 | 26 | export const videoAspect = (video) => { 27 | return `aspect-ratio:${video.aspectRatio.split(':')[0] / video.aspectRatio.split(':')[1]}`; 28 | }; 29 | 30 | export const imageAspect = (image) => { 31 | return `aspect-ratio:${image.dimensions.width / image.dimensions.height}`; 32 | }; 33 | 34 | export const mediaAspect = (media) => { 35 | return media.type === 'video' ? videoAspect(media) : imageAspect(media); 36 | }; 37 | 38 | export const checkIfMediaExists = (media) => { 39 | return (media?.type === 'image' && media?.image) || (media?.type === 'video' && media?.playbackId); 40 | }; 41 | 42 | export const checkIfCtaExists = (cta) => { 43 | return cta?.type !== 'none' && cta?.title && (cta?.slug || cta?.page || cta?.href); 44 | }; 45 | -------------------------------------------------------------------------------- /web/utils/queries.js: -------------------------------------------------------------------------------- 1 | export const seo = ` 2 | seo{ 3 | notIndexed, 4 | metaTitle, 5 | metaDescription, 6 | 'ogImage': ogImage.asset->url, 7 | }, 8 | `; 9 | 10 | export const siteTitle = ` 11 | "global": *[_type == "global"][0]{ 12 | siteTitle, 13 | }, 14 | `; 15 | 16 | export const global = ` 17 | "global": *[_type == "global"][0]{ 18 | siteTitle, 19 | metaDescription, 20 | 'favicon': favicon.asset->url, 21 | 'ogImage': ogImage.asset->url, 22 | mainNavigation[]->{ 23 | _id, 24 | _type, 25 | title, 26 | slug, 27 | }, 28 | social[]{ 29 | ..., 30 | }, 31 | copyright, 32 | navigation[]->, 33 | }, 34 | `; 35 | 36 | export const image = ` 37 | type == "image" => { 38 | 'image': image.asset._ref, 39 | 'alt': image.alt, 40 | 'crop': image.crop, 41 | 'hotspot': image.hotspot, 42 | 'dimensions': image.asset->metadata.dimensions, 43 | 'originalFilename': image.asset->originalFilename, 44 | }, 45 | `; 46 | 47 | export const videoContent = ` 48 | 'thumbTime': video.asset->thumbTime, 49 | 'aspectRatio': video.asset->data.aspect_ratio, 50 | 'playbackId': video.asset->playbackId, 51 | 'mp4Supported': video.asset->data.mp4_support == "standard", 52 | `; 53 | 54 | export const video = ` 55 | type == "video" => { 56 | ${videoContent} 57 | }, 58 | `; 59 | 60 | export const mediaGalleryContent = ` 61 | _key, 62 | _type, 63 | _type == "mainImage" => { 64 | 'image': asset._ref, 65 | 'alt': alt, 66 | 'crop': crop, 67 | 'hotspot': hotspot, 68 | 'dimensions': asset->metadata.dimensions, 69 | 'originalFilename': asset->originalFilename, 70 | }, 71 | _type == "mainVideo" => { 72 | ${videoContent} 73 | }, 74 | `; 75 | 76 | export const mediaGallery = ` 77 | mediaGallery[]{ 78 | ${mediaGalleryContent} 79 | }, 80 | `; 81 | 82 | export const mediaContent = ` 83 | _key, 84 | type, 85 | ${image} 86 | ${video} 87 | `; 88 | 89 | export const media = ` 90 | media{ 91 | ${mediaContent} 92 | }, 93 | `; 94 | 95 | export const ctaContent = ` 96 | type, 97 | title, 98 | type == "internalLink" => { 99 | 'slug': page->slug.current, 100 | 'page': page->_type, 101 | }, 102 | type == "externalLink" => { 103 | href, 104 | }, 105 | `; 106 | 107 | export const cta = ` 108 | cta{ 109 | ${ctaContent} 110 | }, 111 | `; 112 | 113 | export const blockContent = (block) => ` 114 | ${block}[]{ 115 | ..., 116 | _type == "block" => { 117 | markDefs[]{ 118 | _type == "externalLink" => { 119 | ..., 120 | }, 121 | _type == "internalLink" => { 122 | ..., 123 | 'page': page->_type, 124 | 'slug': page->slug.current, 125 | }, 126 | }, 127 | }, 128 | _type == "image" => { 129 | ..., 130 | 'src': image.asset->url, 131 | 'crop': image.crop, 132 | 'hotspot': image.hotspot, 133 | 'dimensions': image.asset->metadata.dimensions, 134 | }, 135 | }, 136 | `; 137 | 138 | export const modules = ` 139 | _type, 140 | _type == "gallery" => { 141 | assets[]{ 142 | ${mediaGalleryContent} 143 | }, 144 | }, 145 | _type == "copy" => { 146 | title, 147 | ${blockContent('copy')} 148 | }, 149 | `; 150 | -------------------------------------------------------------------------------- /web/utils/transitions.js: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap'; 2 | 3 | export const globalTransition = { 4 | name: 'global', 5 | mode: 'out-in', 6 | onBeforeEnter(el) { 7 | gsap.set(el, { opacity: 0 }); 8 | }, 9 | onEnter(el, done) { 10 | gsap.to(el, { opacity: 1, duration: 0.4, onComplete: done }); 11 | }, 12 | onAfterEnter(el) {}, 13 | onEnterCancelled(el) {}, 14 | onBeforeLeave(el) {}, 15 | onLeave(el, done) { 16 | gsap.to(el, { 17 | opacity: 0, 18 | duration: 0.4, 19 | onStart: () => { 20 | if (useRoute().name === 'about') console.log('to page'); 21 | }, 22 | onComplete: done, 23 | }); 24 | }, 25 | onAfterLeave(el) {}, 26 | onLeaveCancelled(el) {}, 27 | }; 28 | 29 | export const projectTransition = { 30 | name: 'project', 31 | mode: 'in-out', 32 | onBeforeEnter(el) { 33 | gsap.set(el, { opacity: 0 }); 34 | }, 35 | onEnter(el, done) { 36 | gsap.to(el, { opacity: 1, duration: 0.4, onComplete: done }); 37 | }, 38 | onAfterEnter(el) {}, 39 | onEnterCancelled(el) {}, 40 | onBeforeLeave(el) {}, 41 | onLeave(el, done) { 42 | const fromEl = select('main').firstElementChild; 43 | const toEl = select('main').lastElementChild; 44 | 45 | done(); 46 | }, 47 | onAfterLeave(el) {}, 48 | onLeaveCancelled(el) {}, 49 | }; 50 | --------------------------------------------------------------------------------