Merge branch 'bootstrap'
This commit is contained in:
@@ -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'],
|
||||
};
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}, []) : []
|
||||
}
|
||||
Reference in New Issue
Block a user