Merge branch 'bootstrap'

This commit is contained in:
Dejvino
2023-05-25 21:20:47 +02:00
25 changed files with 3385 additions and 3980 deletions
+34
View File
@@ -0,0 +1,34 @@
import { PersonalData } from "./PersonalDataTypes";
export const personalData: PersonalData = {
name: "David Hrdina Němeček",
brief: "Software developer, people manager.",
contacts: [
{icon: 'browser-firefox', text: 'www.dejvino.cz'},
{icon: 'envelope-at', text: 'explosive@dejvino.cz'},
{icon: 'git', text: 'https://git.dejvino.cz'}
],
jobs: {
current: {
position: 'Janitor',
company: 'Cleaners Limited',
timerange: '2022 - present',
description: 'Cleanup duty 24/7.',
},
previous: [
{
position: 'CEO',
company: 'CryptoDancers',
timerange: '2019 - 2022',
description: 'Revolutionizing the crypto world.',
}
]
},
skills: {
primary: ['Java', 'TypeScript', 'JavaScript'],
secondary: ['Kotlin', 'Go'],
others: ['Driver\'s license (B)']
},
interests: ['Guitars and Heavy Metal', 'Mazda MX-5', 'DIY electronics'],
};
+31
View File
@@ -0,0 +1,31 @@
export type Contact = {
icon?: string,
text: string
}
export type Job = {
position: string,
company: string,
timerange: string,
description: string
}
export type Jobs = {
current?: Job,
previous?: Job[]
}
export type Skills = {
primary: string[],
secondary?: string[],
others?: string[]
}
export type PersonalData = {
name: string,
brief: string,
contacts: Contact[],
jobs: Jobs,
skills: Skills,
interests: string[]
}
+69
View File
@@ -0,0 +1,69 @@
import { useState, useRef, useEffect, useCallback } from "react";
const focusUrlParamName = 'focus'
const focusChangedEventName = "focus-changed"
const focusedElementClassName = 'focused-element'
function triggerElementFocused(elementKey?: string) {
const url = new URL(window.location.href);
if (elementKey) {
url.searchParams.set(focusUrlParamName, elementKey)
} else {
url.searchParams.delete(focusUrlParamName)
}
const focusChangeEvent = new Event(focusChangedEventName)
history.pushState({}, "", url);
window.dispatchEvent(focusChangeEvent)
}
export function useFocusedElement<ElementType extends HTMLElement>(elementKey: string) {
const [isFocusedElement, setFocusedElement] = useState(false)
const focusedClass = isFocusedElement ? focusedElementClassName : ''
const elementRef = useRef<ElementType>(null)
const focusElement = useCallback(() => {
triggerElementFocused(isFocusedElement ? undefined : elementKey)
}, [isFocusedElement, elementKey])
useEffect(() => {
function updateFocusedState() {
const params = new URLSearchParams(window.location.search)
const focusedElement = params.get(focusUrlParamName)
const focused: boolean = focusedElement && focusedElement == elementKey || false
setFocusedElement(focused)
}
updateFocusedState()
addEventListener(focusChangedEventName, updateFocusedState)
return () => {
removeEventListener(focusChangedEventName, updateFocusedState)
}
}, [elementKey, setFocusedElement, focusElement])
isFocusedElement && elementRef.current?.scrollIntoView()
return {isFocusedElement, focusedClass, elementRef, focusElement}
}
export function useAutoFocus<ElementType extends HTMLElement>(elementKey: string) {
const {elementRef, focusedClass, focusElement} = useFocusedElement<ElementType>(elementKey);
useEffect(() => {
let cleanup = () => {}
if (elementRef.current) {
const elem = elementRef.current
elem.onclick = (evt) => {
evt.stopPropagation()
focusElement()
}
const classNameBackup = elem.className
elem.className += ' focusable ' + focusedClass
cleanup = () => {
elem.className = classNameBackup
}
}
return cleanup
}, [elementRef, focusedClass, focusElement])
return elementRef
}
+15
View File
@@ -0,0 +1,15 @@
import React, { useContext } from 'react';
import Container from 'react-bootstrap/Container';
import Image from 'react-bootstrap/Image'
import { usePersonContext } from './PersonContext';
export default function AboutBrief() {
const person = usePersonContext()
return (
<Container className='about-brief'>
<Image alt='Photograph of the person' rounded={true} src='photo.png'></Image>
<h1>{person.name}</h1>
<p className='brief'>{person.brief}</p>
</Container>
)
}
+42
View File
@@ -0,0 +1,42 @@
import React, { ReactNode } from 'react'
import { usePersonContext } from './PersonContext'
import Container from 'react-bootstrap/esm/Container'
import { useAutoFocus } from '../FocusedElement'
export function Contact(props: {icon?: string, text: string}) {
let textElement: ReactNode = <span className='contact-text'>{props.text}</span>
if (props.text.match(/^(www|http)/)) {
let text = props.text
let url = text
const urlMatch = text.match(/^https?:\/\/(.+)\/?$/)
if (urlMatch) {
text = urlMatch[1]
} else {
url = `https://${props.text}`
}
textElement = <a href={url} className='contact-link' target='_blank'>{text}</a>
}
if (props.text.match(/.+@.+\..+/)) {
const url = `mailto:${props.text}`
textElement = <a href={url} className='contact-link' target='_blank'>{props.text}</a>
}
return (
<span className='contact'>
<i className={'bi-' + props.icon}> </i>
{textElement}
</span>
)
}
export function Contacts() {
const person = usePersonContext()
const focus = useAutoFocus<HTMLDivElement>('contacts')
return (
<Container ref={focus} className='contacts'>
<h2>Contacts</h2>
{person.contacts.map((contact, index) => (
<Contact key={index} {...contact} />
))}
</Container>
)
}
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import Card from 'react-bootstrap/Card';
import { useAutoFocus, useFocusedElement } from '../FocusedElement';
export type Props = {
heading?: string,
position: string,
timerange: string,
company: string,
description: string
};
export default function JobCard(props: Props) {
const focusRef = useAutoFocus<HTMLDivElement>('position ' + [props.position, props.company].join(', '))
return (
<Card ref={focusRef} className='job-card'>
{props.heading && (
<Card.Header>{props.heading}</Card.Header>
)}
<Card.Body>
<Card.Title>{props.position}</Card.Title>
<Card.Subtitle>
<span className='company-name'>{props.company}</span>, <span className='timerange'>{props.timerange}</span>
</Card.Subtitle>
<Card.Text>{props.description}</Card.Text>
</Card.Body>
</Card>
);
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import Container from 'react-bootstrap/Container';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import JobCard from './JobCard';
import { usePersonContext } from './PersonContext';
import { partition } from '../utils';
const entriesPerRow = 2
export default function JobsHistory() {
const person = usePersonContext()
return (
<Container>
<h2>Experience</h2>
{person.jobs.current && (
<Row>
<Col>
<JobCard heading={'Current position'} {...person.jobs.current} />
</Col>
</Row>
)}
{partition(person.jobs.previous, entriesPerRow).map((jobs, index) => (
<Row key={index}>
{(jobs.map((job, subindex) => (
<Col key={index + '_' + subindex}>
<JobCard {...job} />
</Col>
)))}
</Row>
))}
</Container>
)
}
+17
View File
@@ -0,0 +1,17 @@
'use client'
import React, { useContext } from 'react';
import { createContext } from 'react';
import { personalData } from '../../PersonalData';
import { PersonalData } from '../../PersonalDataTypes';
export const PersonContext = createContext(personalData);
export function PersonProvider({ children }) {
return (
<PersonContext.Provider value={personalData}>
{children}
</PersonContext.Provider>
)
}
export const usePersonContext = (): PersonalData => useContext(PersonContext)
+23
View File
@@ -0,0 +1,23 @@
import React, { useContext } from 'react';
import Container from 'react-bootstrap/Container';
import { PersonContext } from './PersonContext';
import TagCloud from './TagCloud';
export default function Skills() {
const person = useContext(PersonContext)
return (
<Container className='skills'>
<h2>Skills</h2>
<TagCloud title='Primary' icon='bookmark-star' style='primary' tags={person.skills.primary} />
{person.skills.secondary && (
<TagCloud title='Secondary' icon='bookmark-plus' tags={person.skills.secondary} />
)}
{person.skills.others && (
<TagCloud title='Others' icon='bookmark' tags={person.skills.others} />
)}
{person.interests && (
<TagCloud title='Interests' icon='bookmark-heart' style='light' tags={person.interests} />
)}
</Container>
)
}
+31
View File
@@ -0,0 +1,31 @@
import React, { useEffect, useRef, useState } from 'react';
import Container from 'react-bootstrap/Container';
import { useAutoFocus, useFocusedElement } from '../FocusedElement';
export type Props = {
title: string,
icon?: string,
tags: string[],
style?: 'primary' | 'light'
}
function Tag(props: {text: string}) {
const tagKey = 'tag_' + props.text;
const elementRef = useAutoFocus(tagKey)
return (
<span ref={elementRef} className='badge text-bg-light'>{props.text}</span>
)
}
export default function TagCloud(props: Props) {
const focusRef = useAutoFocus<HTMLDivElement>('tags ' + props.title)
const containerClasses = ['tag-cloud', 'cloud-' + (props.style || 'standard')]
return (
<Container ref={focusRef} className={containerClasses.join(' ')}>
<h4>{props.icon && (<i className={'bi-' + props.icon}> </i>)}{props.title}</h4>
<Container className='tag-badges'>
{props.tags.map((tag: string) => (<Tag key={tag} text={tag} />) )}
</Container>
</Container>
)
}
+38
View File
@@ -0,0 +1,38 @@
@import 'bootstrap/dist/css/bootstrap.min.css';
@import 'bootstrap-icons/font/bootstrap-icons.min.css';
.job-card {
margin-top: 1em;
}
.job-card .timerange {
font-style: italic;
}
.cloud-primary .tag-badges {
font-size: 180%;
}
.cloud-standard .tag-badges {
font-size: 140%;
}
.cloud-light .tag-badges {
font-size: 130%;
filter: opacity(60%);
}
.tag-badges > span {
margin: 0.4em;
}
.focusable {
transition: all 0.3s;
}
.focusable:hover {
background-color: rgba(236, 241, 245, 0.3);
filter: brightness(96%);
}
.focused-element {
border: 2px dashed red;
}
.contacts .contact {
margin: 0.75em;
}
+29
View File
@@ -0,0 +1,29 @@
import React from 'react'
import { Inter } from 'next/font/google'
import './globals.css'
import { PersonProvider } from './components/PersonContext'
import { personalData } from '../PersonalData'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: personalData.name + ' | CV.',
description: 'Curriculum Vitae',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<PersonProvider>
{children}
</PersonProvider>
</body>
</html>
)
}
+19
View File
@@ -0,0 +1,19 @@
'use client'
import React from 'react';
import Container from 'react-bootstrap/Container';
import JobsHistory from './components/JobsHistory';
import AboutBrief from './components/AboutBrief';
import Skills from './components/Skills';
import { Contacts } from './components/Contacts';
export default function Home() {
return (
<Container fluid='xxl'>
<AboutBrief />
<Contacts />
<JobsHistory />
<Skills />
</Container>
)
}
+10
View File
@@ -0,0 +1,10 @@
export function partition<T>(array: T[]|undefined, entriesPerRow: number): T[][] {
return array ? array.reduce((accumulator: T[][], current: T, index) => {
if (index % entriesPerRow == 0) {
accumulator[accumulator.length] = [current]
} else {
accumulator[accumulator.length - 1]
}
return accumulator
}, []) : []
}