├── .nvmrc ├── cypress.json ├── .babelrc ├── types ├── maybe.d.ts ├── YoutubeVideoSuggestion.d.ts └── supabaseManualEnhanced.ts ├── .eslintrc ├── public └── favicon.ico ├── tests └── e2e │ ├── index.js │ ├── package.json │ ├── .gitignore │ └── tests │ ├── view-event.js │ └── create-event.js ├── .husky └── pre-commit ├── lib ├── sleep.ts ├── dateFnsLocales.ts ├── rtxt │ ├── plugins │ │ ├── pluginUtils.js │ │ ├── index.js │ │ ├── Vimeo.js │ │ ├── Link.js │ │ ├── YouTube.js │ │ ├── Spotify.js │ │ ├── Headlines.js │ │ ├── Spotify.test.js.skip │ │ └── BasePlugin.ts │ ├── rtxt.react.tsx │ └── rtxt.ts ├── getLocale.ts ├── isoDateCompare.ts ├── sortChildEvents.ts ├── legacyEventSlut.ts ├── throwIfNot200.ts ├── loginRequiredToast.tsx ├── useEventCreatedListener.ts ├── getMarkdownContent.ts ├── useGroupedEvents.ts ├── useEvent.ts ├── useEventUpdates.ts ├── screenshot.ts ├── useNativeShareApi.ts ├── useShowEmbed.ts ├── useDifferences.ts ├── TranslationContextProvider.tsx ├── useLikes.ts ├── useLocations.ts ├── UserContextProvider.tsx └── useEvents.ts ├── .vscode └── settings.json ├── next-sitemap.config.js ├── components ├── Box.tsx ├── VerticalScrollArea.tsx ├── FormFieldError.tsx ├── ChromelessButton.tsx ├── Label.tsx ├── HyperLink.tsx ├── Separator.tsx ├── IconButton.tsx ├── LocationDetailData.tsx ├── LinkToEvent.tsx ├── SkipToContent.tsx ├── CheckboxHookForm.tsx ├── EventScore.tsx ├── VerifyEventLink.tsx ├── CheckBox.tsx ├── CookieNotice.tsx ├── EventDeleteLink.tsx ├── ActiveLink.tsx ├── LanguageSwitcher.tsx ├── ShareButton.tsx ├── MissingSomethingTeaser.tsx ├── ToggleGroup.tsx ├── StatusBadge.tsx ├── SidebarPage.tsx ├── CookieNoticeDialog.tsx ├── EventDeleteAlertDialog.tsx ├── Avatar.tsx ├── Collapsible.tsx ├── CloseWithUnsavedChangesDialog.tsx ├── FloatingPanel.tsx ├── MdxTypoProvider.tsx ├── DiffViewer.tsx ├── Loader.tsx ├── Tooltip.tsx ├── CompactEventList.tsx ├── IFrame.tsx ├── Sidebar.tsx ├── Flex.tsx ├── Typo.tsx ├── AlertDialog.tsx ├── ThemeToggle.tsx ├── Grid.tsx ├── Popover.tsx ├── AvatarUpload.tsx ├── CreateEventFormDialog.tsx ├── Footer.tsx ├── Input.tsx ├── YoutubeSearchDialog.tsx ├── LikeButton.tsx └── ShareContent.tsx ├── contents ├── imprint │ └── imprint_de.mdx ├── about │ ├── about_en.mdx │ └── about_de.mdx └── features │ ├── features_en.mdx │ └── features_de.mdx ├── next-env.d.ts ├── utils ├── logtailServer.ts ├── supabaseServiceClient.ts └── supabaseClient.ts ├── scripts ├── setup │ └── README.md └── translations │ └── build.js ├── .github ├── workflows │ ├── auto-merge-dependatbot.yml │ ├── codeql-analysis.yml │ └── lighthouse-audit.yml └── dependabot.yml ├── next-seo.config.ts ├── pages ├── 404.tsx ├── about │ ├── imprint.tsx │ ├── privacy.tsx │ ├── index.tsx │ └── features.tsx ├── _document.tsx ├── api │ ├── e2e-reset.ts │ ├── youtube-search.ts │ ├── notify │ │ ├── feedback.ts │ │ ├── event.ts │ │ └── weekly.ts │ └── webcal.ts ├── create-event.tsx ├── account │ ├── profile.tsx │ └── [action].tsx ├── latest.tsx ├── location │ └── [locationId] │ │ └── index.tsx ├── event │ └── [eventId] │ │ ├── update.tsx │ │ ├── edit.tsx │ │ ├── card.tsx │ │ └── index.tsx ├── index.tsx └── favorites.tsx ├── email ├── sendMail.ts └── templates │ ├── new-event.ts │ ├── new-feedback.ts │ ├── styles.ts │ └── weekly.ts ├── .gitignore ├── tsconfig.json ├── api └── social-image.ts ├── README.md ├── next.config.js ├── stitches.config.ts └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.10.0 2 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /types/maybe.d.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | null; 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "next/core-web-vitals"] 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derkonzert/dk4/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /tests/e2e/index.js: -------------------------------------------------------------------------------- 1 | require("./tests/view-event"); 2 | require("./tests/create-event"); 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run check-types 5 | -------------------------------------------------------------------------------- /lib/sleep.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms) { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": true 4 | } 5 | } -------------------------------------------------------------------------------- /lib/dateFnsLocales.ts: -------------------------------------------------------------------------------- 1 | import { de, enGB } from "date-fns/locale"; 2 | 3 | export const dateFnsLocales = { 4 | de: de, 5 | en: enGB, 6 | }; 7 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | siteUrl: "https://derkonzert.de", 3 | generateRobotsTxt: true, 4 | sitemapSize: 7000, 5 | }; 6 | -------------------------------------------------------------------------------- /components/Box.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "../stitches.config"; 2 | 3 | export const Box = styled("div", { 4 | boxSizing: "border-box", 5 | }); 6 | -------------------------------------------------------------------------------- /lib/rtxt/plugins/pluginUtils.js: -------------------------------------------------------------------------------- 1 | export const matchRegexp = regexp => string => { 2 | const result = regexp.exec(string) 3 | regexp.lastIndex = 0 4 | return result 5 | } 6 | -------------------------------------------------------------------------------- /contents/imprint/imprint_de.mdx: -------------------------------------------------------------------------------- 1 | # Impressum 2 | 3 | Verantwortlich für Website und Inhalte: 4 | 5 | Julian Kempff 6 | Sedanstraße 7 7 | 81667 München 8 | 9 | Kontakt 10 | E-Mail: kontakt@derkonzert.de 11 | -------------------------------------------------------------------------------- /lib/getLocale.ts: -------------------------------------------------------------------------------- 1 | export function getLocale(locale) { 2 | switch (locale) { 3 | case "de": 4 | return require("../locales/de.json"); 5 | default: 6 | return require("../locales/en.json"); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /components/VerticalScrollArea.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "../stitches.config"; 2 | 3 | export const VerticalScrollArea = styled("div", { 4 | display: "flex", 5 | flexDirection: "row", 6 | flexWrap: "nowrap", 7 | overflowX: "auto", 8 | }); 9 | -------------------------------------------------------------------------------- /lib/isoDateCompare.ts: -------------------------------------------------------------------------------- 1 | export function isoDateIsSameDay(isoDate1, isoDate2) { 2 | return isoDate1.slice(0, 10) === isoDate2.slice(0, 10); 3 | } 4 | export function isoDateIsSameMonth(isoDate1, isoDate2) { 5 | return isoDate1.slice(0, 8) === isoDate2.slice(0, 8); 6 | } 7 | -------------------------------------------------------------------------------- /types/YoutubeVideoSuggestion.d.ts: -------------------------------------------------------------------------------- 1 | declare interface YoutubeVideoSuggestion { 2 | videoId: Nullable; 3 | title: Nullable; 4 | thumbnail: Nullable<{ 5 | width?: Nullable; 6 | height?: Nullable; 7 | url?: Nullable; 8 | }>; 9 | } 10 | -------------------------------------------------------------------------------- /lib/sortChildEvents.ts: -------------------------------------------------------------------------------- 1 | import sortBy from "lodash.sortby"; 2 | import { definitions } from "../types/supabase"; 3 | 4 | export function sortChildEvents( 5 | childEvents: Pick[] 6 | ) { 7 | return childEvents ? sortBy(childEvents, (evt) => evt.fromDate) : []; 8 | } 9 | -------------------------------------------------------------------------------- /utils/logtailServer.ts: -------------------------------------------------------------------------------- 1 | import { Logtail } from "@logtail/node"; 2 | 3 | const logtailSourceToken = process.env.LOGTAIL_SOURCE_TOKEN ?? ""; 4 | 5 | export const logtail = new Logtail(logtailSourceToken, {}); 6 | 7 | if (process.env.NODE_ENV !== "production") { 8 | logtail.pipe(process.stdout); 9 | } 10 | -------------------------------------------------------------------------------- /components/FormFieldError.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "../stitches.config"; 2 | 3 | export const FormFieldError = styled("div", { 4 | color: "$red11", 5 | backgroundColor: "$red3", 6 | paddingBlock: "$1", 7 | paddingInline: "$2", 8 | fontSize: "$3", 9 | borderRadius: "$2", 10 | width: "100%", 11 | flex: "1 1 auto", 12 | }); 13 | -------------------------------------------------------------------------------- /lib/legacyEventSlut.ts: -------------------------------------------------------------------------------- 1 | import { validate as validateUUID } from "uuid"; 2 | 3 | export function isLegacyEventSlug(value): boolean { 4 | return !validateUUID(value); 5 | } 6 | 7 | export function getEventShortIdFromLegacyEventSlug(value): string | null { 8 | const parts = value.split("-"); 9 | 10 | return parts[parts.length - 1] || null; 11 | } 12 | -------------------------------------------------------------------------------- /types/supabaseManualEnhanced.ts: -------------------------------------------------------------------------------- 1 | import { definitions } from "./supabase"; 2 | 3 | export type eventWithLocation = Partial & { 4 | id: string; 5 | location?: { 6 | id: string; 7 | name: string; 8 | }; 9 | }; 10 | 11 | export type eventUpdateWithData = definitions["event_updates"] & { 12 | changes: Partial; 13 | }; 14 | -------------------------------------------------------------------------------- /scripts/setup/README.md: -------------------------------------------------------------------------------- 1 | # PostgreSQL Setup: 2 | 3 | Use schema.sql to setup everything. Additionally a trigger can be added, to call the function `handle_new_user` when an insert on auth.users appears. (Creating a new profile for new users) 4 | 5 | ``` 6 | create trigger on_auth_user_created 7 | after insert on auth.users 8 | for each row execute procedure public.handle_new_user(); 9 | ``` 10 | -------------------------------------------------------------------------------- /lib/throwIfNot200.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Useful to make fetch throw if the http status code is not 200: 3 | * @example 4 | * fetch(someUrl).then(throwIfNot200) 5 | * @param response Response 6 | * @returns Response 7 | */ 8 | 9 | export function throwIfNot200(response: Response) { 10 | if (response.status !== 200) { 11 | throw new Error(response.statusText); 12 | } else { 13 | return response; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge-dependatbot.yml: -------------------------------------------------------------------------------- 1 | name: Auto-merge minor/patch 2 | on: 3 | schedule: 4 | - cron: "0 * * * *" 5 | jobs: 6 | test: 7 | name: Auto-merge minor and patch updates 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: koj-co/dependabot-pr-action@master 11 | with: 12 | token: ${{ secrets.GITHUB_TOKEN }} 13 | merge-minor: true 14 | merge-patch: true 15 | -------------------------------------------------------------------------------- /utils/supabaseServiceClient.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | import { Nullable } from "typescript-nullable"; 3 | 4 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; 5 | const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY; 6 | 7 | export const supabaseServiceClient = createClient( 8 | Nullable.withDefault("", supabaseUrl), 9 | Nullable.withDefault("", supabaseServiceKey) 10 | ); 11 | -------------------------------------------------------------------------------- /components/ChromelessButton.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "../stitches.config"; 2 | 3 | export const ChromelessButton = styled("button", { 4 | backgroundColor: "transparent", 5 | fontSize: "inherit", 6 | fontFamily: "inherit", 7 | border: "none", 8 | padding: "0", 9 | cursor: "pointer", 10 | color: "inherit", 11 | 12 | display: "inline-flex", 13 | alignItems: "center", 14 | justifyContent: "center", 15 | lineHeight: 1, 16 | }); 17 | -------------------------------------------------------------------------------- /next-seo.config.ts: -------------------------------------------------------------------------------- 1 | import { DefaultSeoProps } from "next-seo"; 2 | 3 | export const defaultSeoConfig: DefaultSeoProps = { 4 | description: 5 | "Eine von Besuchern kuratierte Liste mit guten Konzerten in München.", 6 | defaultTitle: "derkonzert", 7 | titleTemplate: "%s | derkonzert", 8 | openGraph: { 9 | type: "website", 10 | locale: "de_DE", 11 | url: "https://derkonzert.de/", 12 | site_name: "derkonzert", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /components/Label.tsx: -------------------------------------------------------------------------------- 1 | import { Root as LabelRoot } from "@radix-ui/react-label"; 2 | import { styled } from "../stitches.config"; 3 | 4 | export const Label = styled(LabelRoot, { 5 | display: "block", 6 | fontSize: "$1", 7 | color: "$slate12", 8 | fontWeight: "bold", 9 | fontFamily: "$body", 10 | cursor: "pointer", 11 | 12 | variants: { 13 | checkbox: { 14 | true: { 15 | fontSize: "$3", 16 | fontWeight: "normal", 17 | }, 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /components/HyperLink.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "../stitches.config"; 2 | 3 | export const HyperLink = styled("a", { 4 | color: "$primary", 5 | 6 | variants: { 7 | type: { 8 | ghost: { 9 | textDecoration: "none", 10 | "&:hover": { 11 | textDecoration: "underline", 12 | }, 13 | }, 14 | }, 15 | muted: { 16 | true: { 17 | color: "$slate11", 18 | }, 19 | }, 20 | active: { 21 | true: {}, 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /lib/rtxt/plugins/index.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin } from "./BasePlugin" 2 | 3 | export const defaultPlugin = BasePlugin.create({ 4 | name: "default", 5 | test: ({ line }) => ({ 6 | matches: true, 7 | skipLine: true, 8 | value: line, 9 | }), 10 | renderer: token => token.value, 11 | priority: -1, 12 | }) 13 | 14 | export { Headlines } from "./Headlines" 15 | export { Link } from "./Link" 16 | export { Vimeo } from "./Vimeo" 17 | export { YouTube } from "./YouTube" 18 | export { Spotify } from "./Spotify" 19 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarPage } from "../components/SidebarPage"; 2 | import { TypoHeading, TypoText } from "../components/Typo"; 3 | import { useTranslation } from "../lib/TranslationContextProvider"; 4 | 5 | // pages/404.js 6 | export default function Custom404() { 7 | const { t } = useTranslation(); 8 | 9 | return ( 10 | 11 | {t("global.404.title")} 12 | {t("global.404.description")} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /lib/loginRequiredToast.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import toast from "react-hot-toast"; 3 | import { HyperLink } from "../components/HyperLink"; 4 | 5 | export function loginRequiredToast(t) { 6 | return toast((instance) => ( 7 | 8 | {t("toast.notLoggedIn")}{" "} 9 | 10 | toast.dismiss(instance.id)}> 11 | {t("toast.notLoggedIn.link")} 12 | 13 | 14 | 15 | )); 16 | } 17 | -------------------------------------------------------------------------------- /tests/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "ENVIRONMENT_URL=http://localhost:3000 SCREENSHOTS_DIR=screenshots/ NO_HEADLESS=true node index.js" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "playwright": "^1.13.0" 14 | }, 15 | "dependencies": { 16 | "@radix-ui/react-icons": "^1.0.3", 17 | "@radix-ui/react-toggle-group": "^0.0.10" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /email/sendMail.ts: -------------------------------------------------------------------------------- 1 | import sgMail from "@sendgrid/mail"; 2 | import { Nullable } from "typescript-nullable"; 3 | 4 | export async function sendMail({ to, text, html, subject }) { 5 | if (Nullable.isNone(process.env.SENDGRID_API_KEY)) { 6 | throw new Error("No api key set for email service"); 7 | } 8 | 9 | sgMail.setApiKey(process.env.SENDGRID_API_KEY); 10 | 11 | const from = "noreply@derkonzert.de"; 12 | 13 | return await sgMail.send({ 14 | to, 15 | from, 16 | text, 17 | html, 18 | subject, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /lib/useEventCreatedListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export const eventName = "new-event-created"; 4 | 5 | export function dispatchEventCreatedEvent() { 6 | window.document.body.dispatchEvent(new CustomEvent(eventName)); 7 | } 8 | 9 | export function useEventCreatedListener(handler) { 10 | useEffect(() => { 11 | window.document.body.addEventListener(eventName, handler); 12 | 13 | return () => { 14 | window.document.body.removeEventListener(eventName, handler); 15 | }; 16 | }, [handler]); 17 | } 18 | -------------------------------------------------------------------------------- /components/Separator.tsx: -------------------------------------------------------------------------------- 1 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 2 | import { styled } from "../stitches.config"; 3 | 4 | const StyledSeparator = styled(SeparatorPrimitive.Root, { 5 | backgroundColor: "$slate5", 6 | "&[data-orientation=horizontal]": { 7 | height: 1, 8 | width: "100%", 9 | marginBlock: "$3", 10 | }, 11 | "&[data-orientation=vertical]": { 12 | height: "100%", 13 | width: 1, 14 | marginInline: "$3", 15 | }, 16 | }); 17 | 18 | // Exports 19 | export const Separator = StyledSeparator; 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "../stitches.config"; 2 | 3 | export const IconButton = styled("button", { 4 | all: "unset", 5 | fontFamily: "inherit", 6 | borderRadius: "100%", 7 | height: 42, 8 | width: 42, 9 | cursor: "pointer", 10 | display: "flex", 11 | boxSizing: "border-box", 12 | alignItems: "center", 13 | justifyContent: "center", 14 | color: "$indigo11", 15 | backgroundColor: "$indigo1", 16 | border: `1px solid $colors$indigo4`, 17 | "&:hover": { boxShadow: `0 0 0 1px $colors$indigo9`, color: "$indigo9" }, 18 | "&:focus": { boxShadow: `0 0 0 2px $colors$indigo11` }, 19 | }); 20 | -------------------------------------------------------------------------------- /lib/rtxt/plugins/Vimeo.js: -------------------------------------------------------------------------------- 1 | import { matchRegexp } from "./pluginUtils" 2 | import { BasePlugin } from "./BasePlugin" 3 | 4 | const vimeoMatcher = matchRegexp(/https?:\/\/(www\.)?vimeo.com\/(\d+)($|\/)/) 5 | 6 | export const Vimeo = BasePlugin.create({ 7 | name: "vimeo", 8 | test: ({ word }) => { 9 | const match = vimeoMatcher(word) 10 | if (match) { 11 | return { 12 | value: { 13 | id: match[2], 14 | embedUrl: `https://player.vimeo.com/video/${match[2]}`, 15 | }, 16 | } 17 | } 18 | }, 19 | priority: 10, 20 | renderer: token => `