Init simple Go React blog with Goxygen
This commit is contained in:
parent
634a24fe19
commit
1ada5abed1
31 changed files with 873 additions and 0 deletions
2
simple-blog/.dockerignore
Normal file
2
simple-blog/.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
webapp/node_modules/
|
||||||
|
webapp/build/
|
14
simple-blog/Dockerfile
Normal file
14
simple-blog/Dockerfile
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
FROM node:16.20-alpine3.18 AS JS_BUILD
|
||||||
|
COPY webapp /webapp
|
||||||
|
WORKDIR /webapp
|
||||||
|
RUN npm install && npm run build
|
||||||
|
|
||||||
|
FROM golang:1.22.1-alpine3.18 AS GO_BUILD
|
||||||
|
COPY server /server
|
||||||
|
WORKDIR /server
|
||||||
|
RUN go build -o /go/bin/server
|
||||||
|
|
||||||
|
FROM alpine:3.18.6
|
||||||
|
COPY --from=JS_BUILD /webapp/build* ./webapp/
|
||||||
|
COPY --from=GO_BUILD /go/bin/server ./
|
||||||
|
CMD ./server
|
58
simple-blog/README.md
Normal file
58
simple-blog/README.md
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
# Simple-blog
|
||||||
|
|
||||||
|
## Environment setup
|
||||||
|
|
||||||
|
You need to have [Go](https://golang.org/),
|
||||||
|
[Node.js](https://nodejs.org/),
|
||||||
|
[Docker](https://www.docker.com/), and
|
||||||
|
[Docker Compose](https://docs.docker.com/compose/)
|
||||||
|
(comes pre-installed with Docker on Mac and Windows)
|
||||||
|
installed on your computer.
|
||||||
|
|
||||||
|
Verify the tools by running the following commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go version
|
||||||
|
npm --version
|
||||||
|
docker --version
|
||||||
|
docker-compose --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Start in development mode
|
||||||
|
|
||||||
|
In the project directory run the command (you might
|
||||||
|
need to prepend it with `sudo` depending on your setup):
|
||||||
|
```sh
|
||||||
|
docker-compose -f docker-compose-dev.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts a local PostgreSQL database on `localhost:5432`.
|
||||||
|
The database will be populated with test records from the
|
||||||
|
[init-db.sql](init-db.sql) file.
|
||||||
|
|
||||||
|
Navigate to the `server` folder and start the back end:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd server
|
||||||
|
go run server.go
|
||||||
|
```
|
||||||
|
The back end will serve on http://localhost:8080.
|
||||||
|
|
||||||
|
Navigate to the `webapp` folder, install dependencies,
|
||||||
|
and start the front end development server by running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd webapp
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
The application will be available on http://localhost:3000.
|
||||||
|
|
||||||
|
## Start in production mode
|
||||||
|
|
||||||
|
Perform:
|
||||||
|
```sh
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
This will build the application and start it together with
|
||||||
|
its database. Access the application on http://localhost:8080.
|
12
simple-blog/docker-compose-dev.yml
Normal file
12
simple-blog/docker-compose-dev.yml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
dev_db:
|
||||||
|
image: postgres:15.4-alpine3.18
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: pass
|
||||||
|
POSTGRES_USER: goxygen
|
||||||
|
POSTGRES_DB: goxygen
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
volumes:
|
||||||
|
- ./init-db.sql:/docker-entrypoint-initdb.d/init.sql
|
21
simple-blog/docker-compose.yml
Normal file
21
simple-blog/docker-compose.yml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: app
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
profile: prod
|
||||||
|
db_pass: pass
|
||||||
|
db:
|
||||||
|
image: postgres:15.4-alpine3.18
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: pass
|
||||||
|
POSTGRES_USER: goxygen
|
||||||
|
POSTGRES_DB: goxygen
|
||||||
|
volumes:
|
||||||
|
- ./init-db.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
- ./pgdata:/var/lib/postgresql/data
|
13
simple-blog/init-db.sql
Normal file
13
simple-blog/init-db.sql
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
CREATE TABLE technologies (
|
||||||
|
name VARCHAR(255),
|
||||||
|
details VARCHAR(255)
|
||||||
|
);
|
||||||
|
insert into technologies values (
|
||||||
|
'Go', 'An open source programming language that makes it easy to build simple and efficient software.'
|
||||||
|
);
|
||||||
|
insert into technologies values (
|
||||||
|
'JavaScript', 'A lightweight, interpreted, or just-in-time compiled programming language with first-class functions.'
|
||||||
|
);
|
||||||
|
insert into technologies values (
|
||||||
|
'PostgreSQL', 'A powerful, open source object-relational database system'
|
||||||
|
);
|
36
simple-blog/server/db/db.go
Normal file
36
simple-blog/server/db/db.go
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"simple-blog/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB interface {
|
||||||
|
GetTechnologies() ([]*model.Technology, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostgresDB struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDB(db *sql.DB) DB {
|
||||||
|
return PostgresDB{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d PostgresDB) GetTechnologies() ([]*model.Technology, error) {
|
||||||
|
rows, err := d.db.Query("select name, details from technologies")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var tech []*model.Technology
|
||||||
|
for rows.Next() {
|
||||||
|
t := new(model.Technology)
|
||||||
|
err = rows.Scan(&t.Name, &t.Details)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tech = append(tech, t)
|
||||||
|
}
|
||||||
|
return tech, nil
|
||||||
|
}
|
5
simple-blog/server/go.mod
Normal file
5
simple-blog/server/go.mod
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
module simple-blog
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require github.com/lib/pq v1.10.9
|
2
simple-blog/server/go.sum
Normal file
2
simple-blog/server/go.sum
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
6
simple-blog/server/model/technology.go
Normal file
6
simple-blog/server/model/technology.go
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type Technology struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Details string `json:"details"`
|
||||||
|
}
|
35
simple-blog/server/server.go
Normal file
35
simple-blog/server/server.go
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"simple-blog/db"
|
||||||
|
"simple-blog/web"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
d, err := sql.Open("postgres", dataSource())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
// CORS is enabled only in prod profile
|
||||||
|
cors := os.Getenv("profile") == "prod"
|
||||||
|
app := web.NewApp(db.NewDB(d), cors)
|
||||||
|
err = app.Serve()
|
||||||
|
log.Println("Error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataSource() string {
|
||||||
|
host := "localhost"
|
||||||
|
pass := "pass"
|
||||||
|
if os.Getenv("profile") == "prod" {
|
||||||
|
host = "db"
|
||||||
|
pass = os.Getenv("db_pass")
|
||||||
|
}
|
||||||
|
return "postgresql://" + host + ":5432/goxygen" +
|
||||||
|
"?user=goxygen&sslmode=disable&password=" + pass
|
||||||
|
}
|
63
simple-blog/server/web/app.go
Normal file
63
simple-blog/server/web/app.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"simple-blog/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
d db.DB
|
||||||
|
handlers map[string]http.HandlerFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp(d db.DB, cors bool) App {
|
||||||
|
app := App{
|
||||||
|
d: d,
|
||||||
|
handlers: make(map[string]http.HandlerFunc),
|
||||||
|
}
|
||||||
|
techHandler := app.GetTechnologies
|
||||||
|
if !cors {
|
||||||
|
techHandler = disableCors(techHandler)
|
||||||
|
}
|
||||||
|
app.handlers["/api/technologies"] = techHandler
|
||||||
|
app.handlers["/"] = http.FileServer(http.Dir("/webapp")).ServeHTTP
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Serve() error {
|
||||||
|
for path, handler := range a.handlers {
|
||||||
|
http.Handle(path, handler)
|
||||||
|
}
|
||||||
|
log.Println("Web server is available on port 8080")
|
||||||
|
return http.ListenAndServe(":8080", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetTechnologies(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
technologies, err := a.d.GetTechnologies()
|
||||||
|
if err != nil {
|
||||||
|
sendErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = json.NewEncoder(w).Encode(technologies)
|
||||||
|
if err != nil {
|
||||||
|
sendErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendErr(w http.ResponseWriter, code int, message string) {
|
||||||
|
resp, _ := json.Marshal(map[string]string{"error": message})
|
||||||
|
http.Error(w, string(resp), code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needed in order to disable CORS for local development
|
||||||
|
func disableCors(h http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||||
|
h(w, r)
|
||||||
|
}
|
||||||
|
}
|
57
simple-blog/server/web/app_test.go
Normal file
57
simple-blog/server/web/app_test.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"simple-blog/model"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockDb struct {
|
||||||
|
tech []*model.Technology
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDb) GetTechnologies() ([]*model.Technology, error) {
|
||||||
|
return m.tech, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApp_GetTechnologies(t *testing.T) {
|
||||||
|
app := App{d: &MockDb{
|
||||||
|
tech: []*model.Technology{
|
||||||
|
{"Tech1", "Details1"},
|
||||||
|
{"Tech2", "Details2"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
r, _ := http.NewRequest("GET", "/api/technologies", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
app.GetTechnologies(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("handler returned wrong status code: got %v want %v", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `[{"name":"Tech1","details":"Details1"},{"name":"Tech2","details":"Details2"}]` + "\n"
|
||||||
|
if got := w.Body.String(); got != want {
|
||||||
|
t.Errorf("handler returned unexpected body: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApp_GetTechnologies_WithDBError(t *testing.T) {
|
||||||
|
app := App{d: &MockDb{
|
||||||
|
tech: nil,
|
||||||
|
err: errors.New("unknown error"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
r, _ := http.NewRequest("GET", "/api/technologies", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
app.GetTechnologies(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("handler returned wrong status code: got %v want %v", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
1
simple-blog/webapp/.env.development
Normal file
1
simple-blog/webapp/.env.development
Normal file
|
@ -0,0 +1 @@
|
||||||
|
REACT_APP_API_URL=http://localhost:8080
|
1
simple-blog/webapp/.env.production
Normal file
1
simple-blog/webapp/.env.production
Normal file
|
@ -0,0 +1 @@
|
||||||
|
REACT_APP_API_URL=
|
37
simple-blog/webapp/package.json
Normal file
37
simple-blog/webapp/package.json
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "simple-blog",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "~1.2.1",
|
||||||
|
"react": "~18.2.0",
|
||||||
|
"react-dom": "~18.2.0",
|
||||||
|
"react-scripts": "~5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "~5.16.5",
|
||||||
|
"@testing-library/react": "~13.4.0",
|
||||||
|
"@testing-library/user-event": "~14.4.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "react-app"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
BIN
simple-blog/webapp/public/favicon.ico
Normal file
BIN
simple-blog/webapp/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
43
simple-blog/webapp/public/index.html
Normal file
43
simple-blog/webapp/public/index.html
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using goxygen"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>simple-blog</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start`.
|
||||||
|
To create a production bundle, use `npm run build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
simple-blog/webapp/public/logo192.png
Normal file
BIN
simple-blog/webapp/public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
simple-blog/webapp/public/logo512.png
Normal file
BIN
simple-blog/webapp/public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
25
simple-blog/webapp/public/manifest.json
Normal file
25
simple-blog/webapp/public/manifest.json
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"short_name": "simple-blog",
|
||||||
|
"name": "simple-blog",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
3
simple-blog/webapp/public/robots.txt
Normal file
3
simple-blog/webapp/public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
34
simple-blog/webapp/src/App.css
Normal file
34
simple-blog/webapp/src/App.css
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
body {
|
||||||
|
margin-top: 5%;
|
||||||
|
padding-right: 5%;
|
||||||
|
padding-left: 5%;
|
||||||
|
font-size: larger;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 800px) {
|
||||||
|
body {
|
||||||
|
padding-right: 15%;
|
||||||
|
padding-left: 15%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1600px) {
|
||||||
|
body {
|
||||||
|
padding-right: 30%;
|
||||||
|
padding-left: 30%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
background-color: #b3e6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
}
|
23
simple-blog/webapp/src/App.js
Normal file
23
simple-blog/webapp/src/App.js
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
import './App.css';
|
||||||
|
import Logo from './Logo';
|
||||||
|
import {Tech} from "./tech/Tech";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<h2 className="title">simple-blog</h2>
|
||||||
|
<div className="logo"><Logo/></div>
|
||||||
|
<div>
|
||||||
|
This project is generated with <b><a
|
||||||
|
href="https://github.com/shpota/goxygen">goxygen</a></b>.
|
||||||
|
<p/>The following list of technologies comes from
|
||||||
|
a REST API call to the Go-based back end. Find
|
||||||
|
and change the corresponding code
|
||||||
|
in <code>webapp/src/tech/Tech.js
|
||||||
|
</code> and <code>server/web/app.go</code>.
|
||||||
|
<Tech/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
9
simple-blog/webapp/src/App.test.js
Normal file
9
simple-blog/webapp/src/App.test.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {render} from '@testing-library/react';
|
||||||
|
import {App} from './App';
|
||||||
|
|
||||||
|
test('renders learn react link', () => {
|
||||||
|
const {getByText} = render(<App/>);
|
||||||
|
const linkElement = getByText(/goxygen/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
322
simple-blog/webapp/src/Logo.js
Normal file
322
simple-blog/webapp/src/Logo.js
Normal file
|
@ -0,0 +1,322 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const Logo = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
|
height="150px"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 464.745 338.814"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="linearGradient4289">
|
||||||
|
<stop offset="0" stopColor="#00dce2" stopOpacity="1"></stop>
|
||||||
|
<stop offset="1" stopColor="#97f0ff" stopOpacity="1"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="linearGradient4295"
|
||||||
|
x1="431.334"
|
||||||
|
x2="400.666"
|
||||||
|
y1="531.393"
|
||||||
|
y2="386.128"
|
||||||
|
gradientTransform="translate(129.453 193.74)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
xlinkHref="#linearGradient4289"
|
||||||
|
></linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g display="inline" transform="translate(-111.737 -176.023)">
|
||||||
|
<path
|
||||||
|
fill="url(#linearGradient4295)"
|
||||||
|
fillOpacity="1"
|
||||||
|
fillRule="evenodd"
|
||||||
|
stroke="none"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeOpacity="1"
|
||||||
|
strokeWidth="1"
|
||||||
|
d="M132.042 433.463c23.84-32.316 103.542-15.624 130.056.606 89.595 44.02 169.113-19.05 186.402-33.707 42.52-37.92 60.763-37.33 75.655-30.124 4.45 2.154 10.795 11.465 14.408 12.5 98.104 111.17-186.007 131.629-317.798 102.342-60.452-13.433-102.376-33.11-88.723-51.617z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<g display="none" transform="translate(-111.737 -176.023)">
|
||||||
|
<path
|
||||||
|
fill="#f3df49"
|
||||||
|
fillOpacity="1"
|
||||||
|
fillRule="evenodd"
|
||||||
|
stroke="none"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeOpacity="1"
|
||||||
|
strokeWidth="1"
|
||||||
|
d="M132.042 433.463c23.84-32.316 103.542-15.624 130.056.606 89.595 44.02 169.113-19.05 186.402-33.707 42.52-37.92 60.763-37.33 75.655-30.124 4.45 2.154 10.795 11.465 14.408 12.5 98.104 111.17-186.007 131.629-317.798 102.342-60.452-13.433-102.376-33.11-88.723-51.617z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(17.717 17.717)">
|
||||||
|
<path
|
||||||
|
fill="#2e2e2c"
|
||||||
|
fillOpacity="1"
|
||||||
|
d="M397.824 203.03c-2.159.004-4.045.321-5.6.972-1.554.651-2.778 1.63-3.645 2.857-.866 1.227-1.375 2.699-1.529 4.306-.153 1.607.049 3.349.582 5.126.579 1.93 1.307 3.55 2.192 4.91a13.088 13.088 0 003.107 3.348c1.182.895 2.503 1.59 3.932 2.165 1.428.575 2.962 1.03 4.566 1.444l.553.146.548.15.544.152.54.155c.982.297 1.843.588 2.584.913.74.325 1.364.677 1.87 1.086.506.41.895.875 1.153 1.453.258.577.386 1.265.358 2.152a5.951 5.951 0 01-.47 2.131 6.491 6.491 0 01-1.208 1.864 7.522 7.522 0 01-1.858 1.459 8.952 8.952 0 01-2.42.918c-1.06.24-2.007.293-2.854.183a5.78 5.78 0 01-2.27-.79c-.672-.405-1.267-.943-1.811-1.577-.545-.634-1.037-1.364-1.507-2.154l-1.985 1.807-2.038 1.863-2.09 1.915-2.142 1.96c.653 1.394 1.473 2.66 2.469 3.74a11.564 11.564 0 003.52 2.618c1.353.647 2.886 1.049 4.598 1.16 1.712.11 3.601-.072 5.668-.572 2.113-.512 4.089-1.296 5.865-2.332 1.777-1.035 3.355-2.321 4.663-3.833a16.57 16.57 0 003.055-5.187 17.519 17.519 0 001.046-6.319c-.046-2.03-.392-3.575-.957-4.93-.565-1.354-1.35-2.504-2.325-3.564-.976-1.06-2.123-1.878-3.477-2.66a23.428 23.428 0 00-4.756-2.066l-.546-.168-.554-.16-.56-.157-.57-.14a34.934 34.934 0 01-2.614-.735c-.76-.255-1.408-.529-1.952-.849-.544-.32-.983-.686-1.331-1.128-.348-.441-.605-.957-.792-1.578a3.249 3.249 0 01-.124-1.428c.067-.442.244-.843.525-1.183.281-.34.667-.617 1.15-.817.484-.2 1.065-.32 1.733-.354a8.835 8.835 0 011.843.09 7.096 7.096 0 011.677.485 7.75 7.75 0 011.552.897c.503.37 1 .815 1.513 1.346l1.164-1.306 1.08-1.395 1.126-1.449 1.302-1.465c-.991-1.037-1.863-1.841-2.77-2.497-.906-.656-1.868-1.17-3.04-1.614a20.581 20.581 0 00-3.912-1.033c-1.382-.225-2.43-.278-4.371-.33 0 0 1.894-.003 0 0zm-35.9 4.18l2.956 8.923 2.485 8.916 1.614 9.051.648 9.272a9.355 9.355 0 01-.439 3.521c-.309.977-.768 1.768-1.352 2.408-.584.64-1.294 1.13-2.115 1.507-.82.376-1.752.64-2.78.827-1.073.196-1.992.196-2.801.044a5.873 5.873 0 01-2.145-.869c-.636-.412-1.207-.934-1.756-1.523-.549-.59-1.077-1.246-1.625-1.93l-2.483 1.763-2.427 1.737-2.357 1.71-2.276 1.68c-1.22.901 1.792 2.467 2.93 3.515a15.408 15.408 0 003.934 2.65c1.492.697 3.171 1.185 5.068 1.395 1.897.21 4.013.141 6.381-.281 2.622-.468 5.04-1.253 7.208-2.352 2.168-1.098 4.086-2.511 5.705-4.232a19.5 19.5 0 003.883-6.069c.94-2.32 1.683-4.968 1.511-7.8l-.473-9.292-1.59-8.98-2.622-8.769-3.26-8.728-2.891.48-2.94.48-2.98.476z"
|
||||||
|
display="inline"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeOpacity="1"
|
||||||
|
strokeWidth="2"
|
||||||
|
transform="translate(-111.737 -176.023)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#388e3c"
|
||||||
|
fillOpacity="1"
|
||||||
|
fillRule="evenodd"
|
||||||
|
stroke="#000"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
d="M198.79 380.429c.258-2.631 4.098-13.37 31.374-.952 87.587 39.873 172.374 28.761 238.086 1.513-35.446 31.74-105.09 70.96-151.5 82.622-33.173-.25-69.22-8.332-91.922-33.79-10.377-11.637-27.083-38.739-26.038-49.393z"
|
||||||
|
display="inline"
|
||||||
|
></path>
|
||||||
|
<ellipse
|
||||||
|
cx="280.876"
|
||||||
|
cy="474.384"
|
||||||
|
fill="#1b5e2f"
|
||||||
|
fillOpacity="0.941"
|
||||||
|
stroke="none"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity="1"
|
||||||
|
rx="83.288"
|
||||||
|
ry="22.62"
|
||||||
|
transform="matrix(.9852 -.1714 .13103 .99138 0 0)"
|
||||||
|
></ellipse>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
stroke="#000"
|
||||||
|
strokeOpacity="1"
|
||||||
|
transform="translate(-111.737 -176.023)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#8ed4fe"
|
||||||
|
fillOpacity="1"
|
||||||
|
fillRule="evenodd"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="2.367"
|
||||||
|
d="M369.51 223.971c8.7-7.655 16.286-10.658 23.079-7.257 4.975 2.49 3.88 9.206.186 14.35-4.404 6.131-8.49 9.32-8.744 9.31"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#000"
|
||||||
|
fillOpacity="1"
|
||||||
|
fillRule="evenodd"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeWidth="0.395"
|
||||||
|
d="M377.176 235.834c.04.824 4.25 3.422 5.457 3.096 1.767-.477 8.642-9.057 8.6-10.83-.114-4.972-14.082 7.2-14.057 7.734z"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#8ed4fd"
|
||||||
|
fillOpacity="1"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="bevel"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M388.092 315.25c1.496-1.405 4.867-1.942 7.212-3.04 4.645-2.175 7.611-5.532 6.625-7.496-.985-1.965-5.55-1.794-10.195.382-2.244 1.052-4.386 1.93-6.409 2.138"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#8ed4fd"
|
||||||
|
fillOpacity="1"
|
||||||
|
fillRule="evenodd"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="4"
|
||||||
|
d="M298.548 433.973c-13.22-2.143-19.679-12.58-26.607-23.445-39.8-62.416-31.281-150.252-11.383-173.744 28.95-34.182 95.759-33.51 125.692 5.328 23.506 26.833-1.187 59.973-.604 88.64 42.218 17.603 36.354 74.36 36.354 74.36s-29.164-3.515-46.414.485c-13.469 3.124-29.732 10.114-48.81 19.564-9.17 4.542-16.54 10.707-28.228 8.812z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#8ed4fd"
|
||||||
|
fillOpacity="1"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="bevel"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="4"
|
||||||
|
d="M250.55 329.788c-2.015 1.902-6.565 2.64-9.73 4.129-6.264 2.955-10.258 7.499-8.92 10.148 1.339 2.65 7.503 2.403 13.769-.553 3.025-1.428 5.915-2.621 8.646-2.91"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
fillRule="evenodd"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeWidth="1"
|
||||||
|
d="M391.384 334.295c-9.823-5.145-22.274-2.475-22.274-2.475"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#8ed4fd"
|
||||||
|
fillOpacity="1"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="bevel"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="4"
|
||||||
|
d="M286.83 422.587c6.996 10.92 1.841 23.67 8.24 25.193 5.237 1.246 10.83-11.015 16.597-20.235"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
></path>
|
||||||
|
<circle
|
||||||
|
cx="312.057"
|
||||||
|
cy="259.305"
|
||||||
|
r="33.415"
|
||||||
|
fill="#fff"
|
||||||
|
fillOpacity="1"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="3"
|
||||||
|
opacity="1"
|
||||||
|
></circle>
|
||||||
|
<ellipse
|
||||||
|
cx="383.257"
|
||||||
|
cy="275.775"
|
||||||
|
fill="#fff"
|
||||||
|
fillOpacity="1"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="2.806"
|
||||||
|
opacity="1"
|
||||||
|
rx="23.077"
|
||||||
|
ry="24.17"
|
||||||
|
></ellipse>
|
||||||
|
<g
|
||||||
|
fillOpacity="1"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
transform="translate(4.328 .457)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#fff"
|
||||||
|
fillRule="evenodd"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeWidth="2.564"
|
||||||
|
d="M335.728 298.82c-1.108 3.632-.698 12.213-.698 12.213l10.475 3.152s4.853-5.714 5.444-7.781c.592-2.068.338-3.207.338-3.207z"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#d3b78d"
|
||||||
|
fillRule="evenodd"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeWidth="2.564"
|
||||||
|
d="M327.716 292.849c-7.616 7.896 4.591 8.084 14.163 10.196 7.577 1.67 16.83 10.159 19.098 2.508 3.92-13.223-23.494-22.831-33.261-12.704z"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
></path>
|
||||||
|
<ellipse
|
||||||
|
cx="420.17"
|
||||||
|
cy="-167.764"
|
||||||
|
fill="#000"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="bevel"
|
||||||
|
strokeWidth="1.923"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
rx="11.351"
|
||||||
|
ry="6.498"
|
||||||
|
transform="scale(1 -1) rotate(-18.249)"
|
||||||
|
></ellipse>
|
||||||
|
</g>
|
||||||
|
<circle
|
||||||
|
cx="324.338"
|
||||||
|
cy="267.016"
|
||||||
|
r="8.853"
|
||||||
|
fill="#000"
|
||||||
|
fillOpacity="1"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="2.285"
|
||||||
|
opacity="1"
|
||||||
|
></circle>
|
||||||
|
<circle
|
||||||
|
cx="392.334"
|
||||||
|
cy="283.759"
|
||||||
|
r="6.781"
|
||||||
|
fill="#000"
|
||||||
|
fillOpacity="1"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeDashoffset="0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
opacity="1"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
fill="#8ed4fe"
|
||||||
|
fillOpacity="1"
|
||||||
|
fillRule="evenodd"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeWidth="2.913"
|
||||||
|
d="M278.198 228.795c-3.902-10.61-2.26-20.229 2.508-28.163 3.346-5.568 7.803-6.188 11.596-4.822 6.332 2.281 9.116 9.465 12.68 20.664"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#000"
|
||||||
|
fillOpacity="1"
|
||||||
|
fillRule="evenodd"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeWidth="0.486"
|
||||||
|
d="M288.665 223.718c.788.64 5.95-1.759 7.315-2.47 2.72-1.417.131-13.619-2.11-15.238-4.96-3.583-5.716 17.294-5.205 17.708z"
|
||||||
|
display="inline"
|
||||||
|
opacity="1"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<g display="inline" transform="translate(-111.737 -176.023)">
|
||||||
|
<path
|
||||||
|
fill="#4caf50"
|
||||||
|
fillOpacity="1"
|
||||||
|
fillRule="evenodd"
|
||||||
|
stroke="#000"
|
||||||
|
strokeDasharray="none"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeMiterlimit="4"
|
||||||
|
strokeOpacity="1"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M199.505 378.716c-3.003 5.856-4.34 9.43-3.462 28.282 21.667 47.77 122.45 97.485 235.386 8.578 16.785-13.214 43.013-41.156 70.714-46.428 20.44-3.89 35.552 12.637 35.552 12.637l-9.17-19.116c-10.613-21.217-55.799-16.392-82.11 2.943-35.088 25.784-70.3 86.105-142.844 83.536-79.713-2.823-108.82-63.392-104.066-70.432z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logo;
|
8
simple-blog/webapp/src/index.css
Normal file
8
simple-blog/webapp/src/index.css
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
6
simple-blog/webapp/src/index.js
Normal file
6
simple-blog/webapp/src/index.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import './index.css';
|
||||||
|
import {App} from './App';
|
||||||
|
|
||||||
|
ReactDOM.render(<App/>, document.getElementById('root'));
|
5
simple-blog/webapp/src/setupTests.js
Normal file
5
simple-blog/webapp/src/setupTests.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom/extend-expect';
|
3
simple-blog/webapp/src/tech/Tech.css
Normal file
3
simple-blog/webapp/src/tech/Tech.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.technologies {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
29
simple-blog/webapp/src/tech/Tech.js
Normal file
29
simple-blog/webapp/src/tech/Tech.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import React, {Component} from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import "./Tech.css"
|
||||||
|
|
||||||
|
export class Tech extends Component {
|
||||||
|
state = {
|
||||||
|
technologies: []
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
axios.get(`${process.env.REACT_APP_API_URL}/api/technologies`)
|
||||||
|
.then(resp => this.setState({
|
||||||
|
technologies: resp.data
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const technologies = this.state.technologies.map((technology, i) =>
|
||||||
|
<li key={i}>
|
||||||
|
<b>{technology.name}</b>: {technology.details}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ul className="technologies">
|
||||||
|
{technologies}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue