mirror of
https://github.com/koodiklinikka/koodiklinikka.fi.git
synced 2026-03-06 18:01:12 +00:00
feat: new design for koodiklinikka.fi
Co-authored-by: Aarni Koskela <akx@iki.fi>
This commit is contained in:
committed by
Aarni Koskela
parent
2791108118
commit
33f35b4a5a
5
components/BottomFade.tsx
Normal file
5
components/BottomFade.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export default function BottomFade() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 h-32 bg-gradient-to-t from-[#070b1e] to-black/0"></div>
|
||||
);
|
||||
}
|
||||
33
components/ChannelGrid.tsx
Normal file
33
components/ChannelGrid.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import shuffle from 'lodash.shuffle';
|
||||
|
||||
const DELAYS = shuffle([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1]);
|
||||
|
||||
export default function ChannelGrid({ channels }: { channels: Channel[] }) {
|
||||
return (
|
||||
<div className="mt-8 grid gap-3 rounded-3xl bg-gradient-to-b from-black/10 to-black/0 p-6 backdrop-blur-sm xs:grid-cols-2 sm:grid-cols-3 md:grid-cols-4 md:p-12">
|
||||
{channels.map((channel, i) => (
|
||||
<div key={channel.id} className="relative h-[5.5rem]">
|
||||
<div
|
||||
className="fade-in-out absolute bottom-0 left-0 right-0 top-0 z-0 rounded-[9px] bg-fuchsia-200/40"
|
||||
style={{
|
||||
WebkitMask: 'linear-gradient(to bottom, rgba(0,0,0,1), rgba(0,0,0,0))',
|
||||
animationDelay: `${DELAYS[i] * 2}s`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 top-0 z-10 m-px rounded-[8px] bg-[#2c0c33]"
|
||||
style={{ WebkitMask: 'linear-gradient(to bottom, rgba(0,0,0,.9), rgba(0,0,0,0))' }}
|
||||
></div>
|
||||
<div className="relative z-20 flex flex-col items-center justify-center gap-1 px-3 py-6 font-mono">
|
||||
<a
|
||||
href={`https://app.slack.com/client/T03BQ3NU9/${channel.id}`}
|
||||
target="_blank"
|
||||
className="text-sm font-semibold underline-offset-4 hover:underline"
|
||||
>{`#${channel.name}`}</a>
|
||||
<div className="text-xs opacity-70">{channel.num_members} jäsentä</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import React from "react";
|
||||
|
||||
function renderStringWithChannelRefs(value: string) {
|
||||
return (
|
||||
<>
|
||||
{value.split(/(<#[A-Z0-9]+\|[A-Za-z0-9]+>)/).map((str, i) => {
|
||||
const matches = str.match(/<#([A-Z0-9]+)\|([A-Za-z0-9]+)>/);
|
||||
if (matches) {
|
||||
return (
|
||||
<a
|
||||
href={`https://app.slack.com/client/T03BQ3NU9/${matches[1]}`}
|
||||
key={i}
|
||||
>
|
||||
#{matches[2]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <React.Fragment key={i}>{str}</React.Fragment>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChannelReferenceRenderer = ({
|
||||
children,
|
||||
}: React.PropsWithChildren<{}>) => {
|
||||
// TODO: this should probably walk the tree
|
||||
if (typeof children[0] === "string")
|
||||
return renderStringWithChannelRefs(children[0]);
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export default function EmailComponent() {
|
||||
return <a href="mailto:info@koodiklinikka.fi">info@koodiklinikka.fi</a>;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
function clamp(min, max, value) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export default class Fader extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
threshold: 100,
|
||||
};
|
||||
|
||||
state = {
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
onScroll = () => {
|
||||
const scrollableDistance = document.body.scrollHeight - window.innerHeight,
|
||||
scrollTop = window.pageYOffset || document.documentElement.scrollTop,
|
||||
distanceToBottom = scrollableDistance - scrollTop;
|
||||
|
||||
this.setState({
|
||||
opacity: clamp(0, 1, distanceToBottom / this.props.threshold),
|
||||
});
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("scroll", this.onScroll);
|
||||
this.onScroll();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("scroll", this.onScroll);
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
opacity: this.state.opacity,
|
||||
};
|
||||
|
||||
return <div className="fader" style={style}></div>;
|
||||
}
|
||||
}
|
||||
27
components/FeatureImage.tsx
Normal file
27
components/FeatureImage.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function FeatureImage({
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
alt,
|
||||
}: {
|
||||
src: string;
|
||||
width: number | `${number}`;
|
||||
height: number | `${number}`;
|
||||
alt: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative rounded-[18px] p-px shadow-2xl">
|
||||
<div
|
||||
className="fade-in-out absolute bottom-0 left-0 right-0 top-0 z-0 rounded-[17px] bg-gradient-to-tr from-[#4d094e] to-pink-500/70 p-[2px] "
|
||||
style={{
|
||||
WebkitMask: 'linear-gradient(to bottom, rgba(0,0,0,1), rgba(0,0,0,0))',
|
||||
animationDelay: `${Math.floor(Math.random() * 5) + 1 * 0.5}s`,
|
||||
}}
|
||||
></div>
|
||||
|
||||
<Image className="relative z-10 block rounded-2xl " src={src} alt={alt} width={width} height={height} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import flatMap from "lodash/flatMap";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import React from "react";
|
||||
import request from "axios";
|
||||
import api from "./api";
|
||||
import transformers from "./feed-transformers";
|
||||
import ReactTimeAgo from "react-time-ago";
|
||||
import JavascriptTimeAgo from "javascript-time-ago";
|
||||
import timeagoFi from "javascript-time-ago/locale/fi";
|
||||
|
||||
JavascriptTimeAgo.addLocale(timeagoFi);
|
||||
|
||||
export default class Feed extends React.Component {
|
||||
state = {
|
||||
messages: [],
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.updateFeed();
|
||||
}
|
||||
|
||||
async updateFeed() {
|
||||
const res = await request.get(api("feeds"));
|
||||
const messages = sortBy(
|
||||
flatMap(res.data, (messages, type) => transformers[type](messages)),
|
||||
"timestamp"
|
||||
);
|
||||
messages.reverse(); // In-place
|
||||
this.setState({
|
||||
messages: messages.slice(0, 40),
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const messages = this.state.messages.map((message, i) => {
|
||||
let image = <img src={message.image} alt="" loading="lazy" />;
|
||||
|
||||
if (message.imageLink) {
|
||||
image = (
|
||||
<a
|
||||
target="_blank"
|
||||
href={message.imageLink}
|
||||
rel="noopener noreferrer"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{image}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message" key={i}>
|
||||
<div className="message__image" aria-hidden="true">
|
||||
{image}
|
||||
</div>
|
||||
<div className="message__content">
|
||||
<div className="message__user">
|
||||
<a href={message.userLink}>{message.user}</a>
|
||||
</div>
|
||||
<div
|
||||
className="message__body"
|
||||
dangerouslySetInnerHTML={{ __html: message.body }}
|
||||
/>
|
||||
<div className="message__icon">
|
||||
<i className={`fa fa-${message.type}`} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="message__details">
|
||||
<span className="message__timestamp">
|
||||
<ReactTimeAgo date={message.timestamp} locale="fi" />
|
||||
</span>
|
||||
<span className="message__meta">{message.meta}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="feed">{messages}</div>;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,49 @@
|
||||
import React from "react";
|
||||
import EmailComponent from "./EmailComponent";
|
||||
import sponsors from "../data/sponsors";
|
||||
import Image from 'next/image';
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const SponsorLink = ({ href, name }: Props) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={`/static/images/sponsors/${name.toLowerCase()}.svg`}
|
||||
alt={name}
|
||||
className={`sponsor sponsor__${name.toLowerCase()}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
||||
const SocialLink = ({ href, name, title }: Props) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" aria-label={title}>
|
||||
<img
|
||||
src={`/static/images/social/${name.toLowerCase()}.svg`}
|
||||
alt={title}
|
||||
className={`social social__${name.toLowerCase()}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
||||
export function Footer() {
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer>
|
||||
<div className="sponsors">
|
||||
<div className="sponsors__label">Yhteistyössä</div>
|
||||
{sponsors.map((sponsor) => (
|
||||
<SponsorLink key={sponsor.name} {...sponsor} />
|
||||
))}
|
||||
<div className="space-y-10 pt-24 text-center">
|
||||
<div className="flex items-center justify-center gap-10 ">
|
||||
<a
|
||||
href="https://koodiklinikka.slack.com/"
|
||||
className="opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Image className="size-8" width="800" height="800" src="/logos/slack.svg" alt="Koodiklinikka Slack" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/koodiklinikka"
|
||||
className="opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Image className="size-8" width="98" height="96" src="/logos/github.svg" alt="Koodiklinikka GitHub" />
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/koodiklinikka"
|
||||
className="opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Image className="size-8" width="300" height="300" src="/logos/x.svg" alt="Koodiklinikka X" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.facebook.com/koodiklinikka"
|
||||
className="opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Image className="size-8" width="40" height="40" src="/logos/facebook.svg" alt="Koodiklinikka Facebook" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.linkedin.com/groups/12025476"
|
||||
className="opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Image className="size-8" width="531" height="530" src="/logos/linkedin.svg" alt="Koodiklinikka LinkedIn" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="contacts">
|
||||
<div>
|
||||
<SocialLink
|
||||
href="https://koodiklinikka.slack.com"
|
||||
title="Koodiklinikka Slackissä"
|
||||
name="slack"
|
||||
/>
|
||||
<SocialLink
|
||||
href="https://github.com/koodiklinikka"
|
||||
title="Koodiklinikka Githubissa"
|
||||
name="github"
|
||||
/>
|
||||
<SocialLink
|
||||
href="https://twitter.com/koodiklinikka"
|
||||
title="Koodiklinikka Twitterissä"
|
||||
name="twitter"
|
||||
/>
|
||||
<SocialLink
|
||||
href="https://www.linkedin.com/groups/12025476"
|
||||
title="Koodiklinikka Linkedinissä"
|
||||
name="linkedin"
|
||||
/>
|
||||
<SocialLink
|
||||
href="https://www.facebook.com/koodiklinikka"
|
||||
title="Koodiklinikka Facebookissa"
|
||||
name="facebook"
|
||||
/>
|
||||
<div id="email">
|
||||
<EmailComponent />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="mailto:info@koodiklinikka.fi"
|
||||
className="font-mono text-xs opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
info@koodiklinikka.fi
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
138
components/Form.tsx
Normal file
138
components/Form.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, ReactNode, useState } from 'react';
|
||||
|
||||
const API_URL = 'https://koodiklinikka-api.fly.dev/invites';
|
||||
|
||||
export default function Form() {
|
||||
const [message, setMessage] = useState<ReactNode | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const response = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(Object.fromEntries(formData)),
|
||||
});
|
||||
|
||||
const data = await response.text();
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (response.status === 200) {
|
||||
setMessage('✅ Kutsu lähetetty antamaasi sähköpostiosoitteeseen.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 400 && data === 'invalid_email') {
|
||||
setMessage('⚠️ Tarkasta syöttämäsi sähköpostiosoite');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 400 && data === 'already_invited') {
|
||||
setMessage('♻️ Sähköpostiosoitteeseen on jo lähetetty kutsu');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 400 && data === 'already_in_team') {
|
||||
setMessage(
|
||||
<span>
|
||||
🤔 Tällä sähköpostilla on jo luotu tunnus.{' '}
|
||||
<a href="https://koodiklinikka.slack.com/forgot" className="underline underline-offset-4">
|
||||
Nollaa unohtunut salasana
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage('⚡ Jotain meni pieleen. Yritä hetken päästä uudelleen.');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-sm text-center md:max-w-xl">
|
||||
{message === null && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2 className="font-mono text-sm font-semibold">
|
||||
Syötä sähköpostiosoitteesi alle ja saat kutsun Slack-yhteisöömme:
|
||||
</h2>
|
||||
|
||||
<div className="my-5 grid grid-cols-4 gap-2">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
className="col-span-3 grow rounded px-3 py-2 text-sm text-fuchsia-950 sm:text-base md:rounded-lg md:px-4 md:py-4 lg:rounded-lg lg:px-5 lg:py-5 lg:text-lg"
|
||||
placeholder="minna.meikalainen@example.org"
|
||||
tabIndex={1}
|
||||
/>
|
||||
<button
|
||||
tabIndex={3}
|
||||
type="submit"
|
||||
className="text-shadow bg-button rounded border border-pink-400 px-3 py-2 text-sm font-extrabold sm:text-base md:rounded-lg md:px-4 md:py-4 lg:px-5 lg:py-5 lg:text-lg"
|
||||
>
|
||||
{isSubmitting ? 'Liitytään' : 'Liity'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-wrap items-center justify-center gap-2 font-mono text-xxs sm:text-xs">
|
||||
<div className="relative h-5 w-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="terms"
|
||||
required
|
||||
className="h-3 w-3 opacity-5 focus:outline-none"
|
||||
tabIndex={2}
|
||||
/>
|
||||
<div className="checkbox absolute left-0 top-0 flex h-full w-full items-center justify-center rounded border border-white bg-transparent transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
Sitoudun yhteisön
|
||||
<a
|
||||
className="inline-flex items-center gap-1 underline underline-offset-4"
|
||||
href="https://github.com/koodiklinikka/code-of-conduct/blob/master/README.md"
|
||||
target="_blank"
|
||||
>
|
||||
<span>käyttäytymissääntöihin</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</label>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className="text-balance rounded-3xl bg-black/20 p-10 text-center font-mono text-sm backdrop-blur-sm">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
components/Hero.tsx
Normal file
14
components/Hero.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Form from './Form';
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<div className="text-shadow mx-auto flex flex-col items-center justify-center" id="liity">
|
||||
<h1 className="my-14 text-center text-2xl font-extrabold leading-tight sm:max-w-[80%] sm:text-3xl md:my-24 md:text-4xl lg:my-32 lg:text-5xl">
|
||||
Koodiklinikka on Suomen suurin <span className="title-highlight">ohjelmistoalan yhteisö</span>, joka tuo alan
|
||||
ammattilaiset ja harrastajat yhteen
|
||||
</h1>
|
||||
|
||||
<Form />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import request from "axios";
|
||||
import React from "react";
|
||||
import classSet from "classnames";
|
||||
import api from "./api";
|
||||
import Loader from "./Loader";
|
||||
|
||||
export default class InviteForm extends React.Component {
|
||||
state = {
|
||||
email: "",
|
||||
submitted: false,
|
||||
sending: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
this.setState({
|
||||
submitted: false,
|
||||
sending: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await request.post(api("invites"), {
|
||||
email: this.state.email.trim(),
|
||||
});
|
||||
this.handleSuccess();
|
||||
} catch (error) {
|
||||
this.handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
handleSuccess = () => {
|
||||
this.setState({ submitted: true, sending: false });
|
||||
};
|
||||
|
||||
handleError = (err) => {
|
||||
this.setState({ error: err, sending: false });
|
||||
};
|
||||
|
||||
onChange = (e) => {
|
||||
if (e.target.value === this.state.email) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
email: e.target.value,
|
||||
error: null,
|
||||
submitted: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const formClasses = classSet({
|
||||
form: true,
|
||||
"invite-form": true,
|
||||
"has-success": this.state.submitted,
|
||||
"has-error": this.state.error,
|
||||
sending: this.state.sending,
|
||||
});
|
||||
|
||||
const inputClasses = classSet({
|
||||
input: true,
|
||||
"invite-form__input": true,
|
||||
"has-success": this.state.submitted,
|
||||
"has-error": this.state.error,
|
||||
});
|
||||
|
||||
let feedbackMessage;
|
||||
|
||||
if (this.state.error || this.state.submitted) {
|
||||
let messageText;
|
||||
|
||||
if (this.state.submitted) {
|
||||
messageText = "Kutsu lähetetty antamaasi sähköpostiosoitteeseen.";
|
||||
} else if (
|
||||
this.state.error.response.status === 400 &&
|
||||
this.state.error.response.data === "invalid_email"
|
||||
) {
|
||||
messageText = "Tarkasta syöttämäsi sähköpostiosoite";
|
||||
} else if (
|
||||
this.state.error.response.status === 400 &&
|
||||
this.state.error.response.data === "already_invited"
|
||||
) {
|
||||
messageText = "Sähköpostiosoitteeseen on jo lähetetty kutsu";
|
||||
} else if (
|
||||
this.state.error.response.status === 400 &&
|
||||
this.state.error.response.data === "already_in_team"
|
||||
) {
|
||||
messageText = (
|
||||
<span>
|
||||
Tällä sähköpostilla on jo luotu tunnus. <br /> Voit vaihtaa
|
||||
unohtuneen salasanasi{" "}
|
||||
<a href="https://koodiklinikka.slack.com/forgot">täältä</a>.
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
messageText = "Jotain meni pieleen. Yritä hetken päästä uudelleen.";
|
||||
}
|
||||
|
||||
feedbackMessage = <div className="form--message">{messageText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={formClasses} onSubmit={this.onSubmit}>
|
||||
<div className="form__field">
|
||||
<label className="label" htmlFor="email-field">
|
||||
Sähköpostiosoite:
|
||||
</label>
|
||||
<div className="controls-wrapper">
|
||||
<span className="input-wrapper">
|
||||
<input
|
||||
className={inputClasses}
|
||||
type="text"
|
||||
name="email"
|
||||
id="email-field"
|
||||
// Placeholder is not accessible way to provide information
|
||||
// Used here for :placeholder-shown -styles
|
||||
placeholder=""
|
||||
value={this.state.email}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
<div className="invite-form__loader">
|
||||
<Loader />
|
||||
</div>
|
||||
</span>
|
||||
<button
|
||||
className="btn btn__submit"
|
||||
type="submit"
|
||||
title="Lähetä"
|
||||
disabled={this.state.error || this.state.submitted}
|
||||
>
|
||||
Lähetä
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{feedbackMessage}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Loader() {
|
||||
return (
|
||||
<div className="sk-folding-cube">
|
||||
<div className="sk-cube1 sk-cube" />
|
||||
<div className="sk-cube2 sk-cube" />
|
||||
<div className="sk-cube4 sk-cube" />
|
||||
<div className="sk-cube3 sk-cube" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from "react";
|
||||
import request from "axios";
|
||||
import shuffle from "lodash/shuffle";
|
||||
import api from "./api";
|
||||
|
||||
export default class Members extends React.Component {
|
||||
state = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.refreshMembers();
|
||||
}
|
||||
|
||||
async refreshMembers() {
|
||||
const res = await request.get(api("members"));
|
||||
this.setState({
|
||||
members: shuffle(res.data),
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const members = this.state.members.map((member) => {
|
||||
const src = `${member.avatar_url}&s=120`;
|
||||
return (
|
||||
<img
|
||||
className="member"
|
||||
key={member.avatar_url}
|
||||
src={src}
|
||||
alt=""
|
||||
width={30}
|
||||
height={30}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="members" aria-hidden="true">
|
||||
<a
|
||||
href="https://github.com/koodiklinikka"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{members}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
82
components/Nav.tsx
Normal file
82
components/Nav.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import Wrapper from './Wrapper';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function Nav() {
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const navRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (navRef.current && !navRef.current.contains(event.target as Node)) {
|
||||
setNavOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="fixed left-0 top-0 z-50 h-32 w-full bg-gradient-to-b from-black/40 to-fuchsia-950/0">
|
||||
<Wrapper>
|
||||
<div className="relative flex items-center justify-between px-6 py-5 md:px-12">
|
||||
<div className="shrink-0">
|
||||
<Image src="/koodiklinikka.svg" alt="Koodiklinikka" width="179" height="34" className="w-40" priority />
|
||||
</div>
|
||||
<div ref={navRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-2 rounded bg-black/0 p-2 hover:bg-black/20 lg:hidden"
|
||||
onMouseDown={() => setNavOpen(!navOpen)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="size-6"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className={`${navOpen ? 'flex' : 'hidden'} text-shadow absolute right-6 top-16 flex-col gap-5 rounded-lg bg-black/80 p-5 text-xs uppercase tracking-widest text-pink-100 backdrop-blur-md lg:static lg:flex lg:flex-row lg:bg-transparent lg:backdrop-blur-none`}
|
||||
>
|
||||
<Link className="underline-offset-4 hover:underline" href="https://github.com/koodiklinikka">
|
||||
GitHub
|
||||
</Link>
|
||||
<NavSeparator />
|
||||
<Link className="underline-offset-4 hover:underline" href="https://koodiklinikka.slack.com">
|
||||
Slack
|
||||
</Link>
|
||||
<NavSeparator />
|
||||
<Link className="underline-offset-4 hover:underline" href="https://resources.koodiklinikka.fi">
|
||||
Resources
|
||||
</Link>
|
||||
<NavSeparator />
|
||||
<Link className="underline-offset-4 hover:underline" href="https://koodiklinikka.myspreadshop.fi/">
|
||||
Shop
|
||||
</Link>
|
||||
<NavSeparator />
|
||||
<Link
|
||||
className="underline-offset-4 hover:underline"
|
||||
href="https://github.com/koodiklinikka/code-of-conduct"
|
||||
>
|
||||
Code of Conduct
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const NavSeparator = () => <div className="hidden opacity-20 lg:block">|</div>;
|
||||
5
components/Wrapper.tsx
Normal file
5
components/Wrapper.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <div className="mx-auto max-w-6xl px-5">{children}</div>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
const host = process.env.SERVER || "https://koodiklinikka-api.fly.dev/";
|
||||
|
||||
export default function getApiURL(path) {
|
||||
return host + path;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import lodashTemplate from "lodash/template";
|
||||
import defaultTemplateSettings from "lodash/templateSettings";
|
||||
import githubEvent from "parse-github-event";
|
||||
import twitterText from "twitter-text";
|
||||
|
||||
const isVisibleGithubEvent = ({ type }) =>
|
||||
type !== "PushEvent" && type !== "DeleteEvent";
|
||||
|
||||
const templateSettings = {
|
||||
...defaultTemplateSettings,
|
||||
interpolate: /{{([\s\S]+?)}}/g,
|
||||
};
|
||||
|
||||
export default {
|
||||
github(items) {
|
||||
return items.filter(isVisibleGithubEvent).map((item) => {
|
||||
const template = lodashTemplate(
|
||||
githubEvent.parse(item).text,
|
||||
templateSettings,
|
||||
false
|
||||
);
|
||||
|
||||
const repository = `https://github.com/${item.repo.name}`;
|
||||
let branch;
|
||||
if (item.payload.ref) {
|
||||
branch = item.payload.ref.replace("refs/heads/", "");
|
||||
}
|
||||
|
||||
const message = template({
|
||||
repository: `<a target="_blank" href="${repository}">${item.repo.name}</a>`,
|
||||
branch: branch,
|
||||
number: item.payload.number,
|
||||
ref_type: item.payload.ref_type,
|
||||
ref: item.payload.ref,
|
||||
});
|
||||
|
||||
const url = `https://github.com/${item.actor.login}`;
|
||||
|
||||
return {
|
||||
user: item.actor.login,
|
||||
userLink: url,
|
||||
image: item.actor.avatar_url,
|
||||
imageLink: url,
|
||||
body: message,
|
||||
timestamp: new Date(item.created_at),
|
||||
url: message.url,
|
||||
type: "github",
|
||||
};
|
||||
});
|
||||
},
|
||||
twitter(items) {
|
||||
return items.map((item) => {
|
||||
if (item.retweeted) {
|
||||
item = item.retweeted_status;
|
||||
}
|
||||
|
||||
const url = `https://twitter.com/${item.user.screen_name}`;
|
||||
|
||||
return {
|
||||
user: `@${item.user.screen_name}`,
|
||||
userLink: url,
|
||||
image: item.user.profile_image_url_https,
|
||||
imageLink: url,
|
||||
body: twitterText.autoLink(item.text),
|
||||
timestamp: new Date(item.created_at),
|
||||
type: "twitter",
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from "react";
|
||||
import MembershipInfoForm from "./MembershipInfoForm";
|
||||
|
||||
export default class MembershipForm extends React.Component {
|
||||
state = {
|
||||
signupSuccess: false,
|
||||
};
|
||||
|
||||
handleSignupSuccess = () => {
|
||||
this.setState({ signupSuccess: true });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.signupSuccess) {
|
||||
return <MembershipInfoForm onSignupSuccess={this.handleSignupSuccess} />;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
height="50"
|
||||
width="50"
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="#349c4a"
|
||||
d="M256 6.998c-137.533 0-249 111.467-249 249 0 137.534 111.467 249 249 249s249-111.467 249-249c0-137.534-111.467-249-249-249zm0 478.08c-126.31 0-229.08-102.77-229.08-229.08 0-126.31 102.77-229.08 229.08-229.08 126.31 0 229.08 102.77 229.08 229.08 0 126.31-102.77 229.08-229.08 229.08z"
|
||||
/>
|
||||
<path
|
||||
fill="#349c4a"
|
||||
d="M384.235 158.192L216.92 325.518 127.86 236.48l-14.142 14.144 103.2 103.18 181.36-181.47"
|
||||
/>
|
||||
</svg>
|
||||
<p> Rekisteröityminen onnistui. Tervetuloa jäseneksi!</p>
|
||||
<p> Tervetuloa Koodiklinikka ry:n jäseneksi!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import request from "axios";
|
||||
import React from "react";
|
||||
import classSet from "classnames";
|
||||
import api from "../api";
|
||||
import Loader from "../Loader";
|
||||
|
||||
type Props = {
|
||||
onSignupSuccess: () => void;
|
||||
};
|
||||
|
||||
type State = {
|
||||
error: boolean;
|
||||
errors: string[];
|
||||
fields: {
|
||||
name: string;
|
||||
email: string;
|
||||
handle: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
city: string;
|
||||
};
|
||||
sending: boolean;
|
||||
pristineFields: string[];
|
||||
};
|
||||
|
||||
const fieldNameTranslations = {
|
||||
address: { fi: "Osoite" },
|
||||
city: { fi: "Paikkakunta" },
|
||||
email: { fi: "Sähköpostiosoite" },
|
||||
handle: { fi: "Slack-käyttäjätunnus " },
|
||||
name: { fi: "Koko nimi " },
|
||||
postcode: { fi: "Postinumero" },
|
||||
};
|
||||
|
||||
const mailValidateRe =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
|
||||
function validateEmail(email) {
|
||||
return mailValidateRe.test(email);
|
||||
}
|
||||
|
||||
function getUserInfo(state) {
|
||||
return state.fields;
|
||||
}
|
||||
|
||||
export default class MembershipInfoForm extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.setState({
|
||||
fields: {
|
||||
address: "",
|
||||
city: "",
|
||||
email: "",
|
||||
handle: "",
|
||||
name: "",
|
||||
postcode: "",
|
||||
},
|
||||
sending: false,
|
||||
pristineFields: Object.keys(this.state.fields),
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit = async () => {
|
||||
this.setState({
|
||||
sending: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await request.post(api("membership"), {
|
||||
userInfo: getUserInfo(this.state),
|
||||
});
|
||||
this.setState({ sending: false });
|
||||
this.props.onSignupSuccess();
|
||||
} catch (err) {
|
||||
this.setState({ error: err, sending: false });
|
||||
}
|
||||
};
|
||||
|
||||
onChange = (e) => {
|
||||
const name = e.target.name;
|
||||
if (e.target.value === this.state[name]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
fields: {
|
||||
...this.state.fields,
|
||||
[name]: e.target.value,
|
||||
},
|
||||
pristineFields: this.state.pristineFields.filter(
|
||||
(fieldName) => fieldName !== name
|
||||
),
|
||||
errors: [],
|
||||
});
|
||||
};
|
||||
|
||||
getDataErrors = () => {
|
||||
const foundErrors = [];
|
||||
|
||||
Object.keys(this.state.fields).forEach((fieldName) => {
|
||||
if (!this.state[fieldName]) {
|
||||
foundErrors.push({ field: fieldName, type: "missing" });
|
||||
}
|
||||
});
|
||||
|
||||
if (this.state.fields.email && !validateEmail(this.state.fields.email)) {
|
||||
foundErrors.push({ field: "email", type: "invalid" });
|
||||
}
|
||||
|
||||
return foundErrors;
|
||||
};
|
||||
|
||||
render() {
|
||||
const inputErrors = this.getDataErrors();
|
||||
|
||||
const formClasses = classSet({
|
||||
form: true,
|
||||
"membership-form": true,
|
||||
"has-error": inputErrors.length !== 0 || this.state.error,
|
||||
sending: this.state.sending,
|
||||
});
|
||||
|
||||
function getErrorMessage(err) {
|
||||
let feedbackText;
|
||||
|
||||
const fieldName = fieldNameTranslations[err.field].fi;
|
||||
if (err.type === "missing") {
|
||||
feedbackText = `${fieldName} on pakollinen.`;
|
||||
} else if (err.type === "invalid") {
|
||||
feedbackText = `${fieldName} on virheellinen.`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={err.field} className="form--message">
|
||||
{feedbackText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* generate error messages */
|
||||
const visibleErrors = inputErrors.filter(
|
||||
(error) => this.state.pristineFields.indexOf(error.field) === -1
|
||||
);
|
||||
|
||||
const fieldsWithErrors = visibleErrors.map(({ field }) => field);
|
||||
|
||||
const inputFields = Object.keys(this.state.fields).map((fieldName) => {
|
||||
const inputClasses = classSet({
|
||||
input: true,
|
||||
"has-error": fieldsWithErrors.includes(fieldName),
|
||||
half: fieldName === "city" || fieldName === "postcode",
|
||||
left: fieldName === "postcode",
|
||||
});
|
||||
|
||||
function showsErrorFor(field) {
|
||||
if (fieldName === "city") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
field === fieldName || (fieldName === "postcode" && field === "city")
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={fieldName}>
|
||||
<input
|
||||
className={inputClasses}
|
||||
type={fieldName === "email" ? "email" : "text"}
|
||||
name={fieldName}
|
||||
placeholder={fieldNameTranslations[fieldName].fi}
|
||||
value={this.state[fieldName]}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
{visibleErrors
|
||||
.filter(({ field }) => showsErrorFor(field))
|
||||
.map(getErrorMessage)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
if (this.state.sending) {
|
||||
return (
|
||||
<div className="membership-form__loader">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h3>Liity jäseneksi</h3>
|
||||
<form className={formClasses}>
|
||||
{inputFields}
|
||||
{this.state.error && (
|
||||
<div className="form--message">
|
||||
Jotain meni pieleen! Ota yhteyttä info@koodiklinikka.fi
|
||||
</div>
|
||||
)}
|
||||
<br />
|
||||
<button
|
||||
type="button"
|
||||
disabled={inputErrors.length !== 0}
|
||||
className="btn btn__submit"
|
||||
onClick={this.onSubmit}
|
||||
>
|
||||
Liity jäseneksi
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user