chore: sync branch with latest changes
This commit is contained in:
commit
e536fc8ea5
83
.github/workflows/image.yml
vendored
Normal file
83
.github/workflows/image.yml
vendored
Normal file
@ -0,0 +1,83 @@
|
||||
name: Build and publish container images
|
||||
|
||||
on:
|
||||
push: { branches: [ main ] }
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
outputs:
|
||||
commit: ${{ steps.metadata.outputs.commit }}
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [ amd64, arm64v8 ]
|
||||
include:
|
||||
- architecture: amd64
|
||||
platform: linux/amd64
|
||||
- architecture: arm64v8
|
||||
platform: linux/arm64
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Obtain project metadata
|
||||
id: metadata
|
||||
run: echo "commit=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate image metadata
|
||||
uses: docker/metadata-action@v5
|
||||
id: image-metadata
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ matrix.architecture }}-${{ steps.metadata.outputs.commit }},enable={{is_default_branch}}
|
||||
type=raw,value=${{ matrix.architecture }},enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push platform specific images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ steps.image-metadata.outputs.tags }}
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [ build ]
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ github.repository }}
|
||||
COMMIT: ${{ needs.build.outputs.commit }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate manifest for multi-arch images from source manifests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag ${IMAGE}:${COMMIT} ${IMAGE}:{amd64,arm64v8}-${COMMIT}
|
||||
docker buildx imagetools create \
|
||||
--tag ${IMAGE}:latest ${IMAGE}:{amd64,arm64v8}
|
17
.github/workflows/test.yml
vendored
Normal file
17
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
name: Test and Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- run: go install github.com/a-h/templ/cmd/templ@latest
|
||||
- run: make build
|
||||
#- run: make test
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -1 +1,4 @@
|
||||
dumb
|
||||
views/*_templ.go
|
||||
esbuild
|
||||
static/*.css
|
||||
|
21
Dockerfile
21
Dockerfile
@ -1,4 +1,6 @@
|
||||
FROM golang:1.19.4-alpine3.17
|
||||
FROM golang:1.22.2-alpine3.19 AS build
|
||||
|
||||
RUN apk add make git curl
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
@ -6,8 +8,21 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go build
|
||||
RUN make build
|
||||
|
||||
###############################################################
|
||||
|
||||
FROM scratch
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/rramiachraf/dumb"
|
||||
LABEL org.opencontainers.image.url="https://github.com/rramiachraf/dumb"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.description="Private alternative front-end for Genius."
|
||||
|
||||
COPY --from=build /code/dumb .
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
EXPOSE 5555/tcp
|
||||
|
||||
CMD ["/code/dumb"]
|
||||
CMD ["./dumb"]
|
||||
|
||||
|
14
Makefile
Normal file
14
Makefile
Normal file
@ -0,0 +1,14 @@
|
||||
VERSION=`git rev-parse --short HEAD`
|
||||
|
||||
gentempl:
|
||||
command -v templ &> /dev/null || go install github.com/a-h/templ/cmd/templ@latest
|
||||
esbuild:
|
||||
[ ! -f ./esbuild ] && curl -fsSL https://esbuild.github.io/dl/latest | sh
|
||||
build:gentempl esbuild
|
||||
templ generate
|
||||
cat ./style/*.css | ./esbuild --loader=css --minify > ./static/style.css
|
||||
go build -ldflags="-X 'github.com/rramiachraf/dumb/data.Version=$(VERSION)' -s -w"
|
||||
test:
|
||||
go test ./... -v
|
||||
fmt:
|
||||
templ fmt .
|
47
README.md
47
README.md
@ -1,43 +1,44 @@
|
||||
# dumb
|
||||
With the massive daily increase of useless scripts on Genius's web frontend and having to download megabytes of clutter, [dumb](https://github.com/rramiachraf/dumb) tries to make reading lyrics from Genius a pleasant experience and as lightweight as possible.
|
||||
With the massive daily increase of useless scripts on Genius's web frontend, and having to download megabytes of clutter, [dumb](https://github.com/rramiachraf/dumb) tries to make reading lyrics from Genius a pleasant experience, and as lightweight as possible.
|
||||
|
||||
<a href="https://codeberg.org/rramiachraf/dumb"><img src="https://img.shields.io/badge/Codeberg-%232185d0" /></a>
|
||||
|
||||
![Screenshot](https://raw.githubusercontent.com/rramiachraf/dumb/main/screenshot.png)
|
||||
|
||||
## Installation & Usage
|
||||
[Go 1.21+](https://go.dev/dl) is required.
|
||||
### Docker
|
||||
```bash
|
||||
docker run -p 8080:5555 --name dumb ghcr.io/rramiachraf/dumb:latest
|
||||
```
|
||||
|
||||
### Build from source
|
||||
[Go 1.22+](https://go.dev/dl) is required.
|
||||
```bash
|
||||
git clone https://github.com/rramiachraf/dumb
|
||||
cd dumb
|
||||
go build
|
||||
make build
|
||||
./dumb
|
||||
```
|
||||
|
||||
The default port is 5555, you can use other ports by setting the `PORT` environment variable.
|
||||
#### Notes:
|
||||
- The default port is 5555, you can use other ports by setting the `PORT` environment variable.
|
||||
- Genius servers are behind a Cloudflare reverse proxy, which means certain IPs won't be able to send requests, to partially mitigate this, you can specify a proxy by setting the `PROXY` variable (must be a valid URI).
|
||||
|
||||
## Public Instances
|
||||
| URL | Tor | I2P | Region | CDN? | Operator
|
||||
| --- | --- | --- | --- | --- | ---
|
||||
| <https://dumb.ducks.party> | No | No | NL | No | https://ducks.party
|
||||
| <https://dm.vern.cc> | [Yes](http://dm.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | [Yes](http://vernxpcpqi2y4uhu7to4rnjmyjjgzh3x3qxyzpmkhykefchkmleq.b32.i2p) | US | No | https://vern.cc
|
||||
| <https://dumb.lunar.icu> | No | No | DE | Yes | @MaximilianGT500
|
||||
| <https://dumb.privacydev.net> | [Yes](http://dumb.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion) | No | FR | No | https://privacydev.net
|
||||
| <https://dumb.privacyfucking.rocks> | No | No | DE | - | https://privacyfucking.rocks |
|
||||
| <https://sing.whatever.social> | No | No | US/DE | Yes | Whatever Social
|
||||
|
||||
| URL | Region | CDN? | Operator |
|
||||
| --- | --- | --- | --- |
|
||||
| <https://dm.vern.cc> | US | No | https://vern.cc |
|
||||
| <https://sing.whatever.social> | US/DE | Yes | Whatever Social |
|
||||
| <https://dumb.lunar.icu> | DE | Yes | @MaximilianGT500 |
|
||||
| <https://dumb.privacydev.net> | FR | No | https://privacydev.net |
|
||||
| <https://dumb.ducks.party> | NL | No | https://ducks.party |
|
||||
[Status Page](https://github.com/rramiachraf/dumb-instances)
|
||||
|
||||
### Tor
|
||||
| URL | Operator |
|
||||
| --- | --- |
|
||||
| <http://dm.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion> | https://vern.cc |
|
||||
| <http://dumb.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion> | https://privacydev.net |
|
||||
|
||||
### I2P
|
||||
| URL | Operator |
|
||||
| --- | --- |
|
||||
| <http://vernxpcpqi2y4uhu7to4rnjmyjjgzh3x3qxyzpmkhykefchkmleq.b32.i2p> | https://vern.cc |
|
||||
|
||||
For people who might be capable and interested in hosting a public instance feel free to do so and don't forget to open a pull request so your instance can be included here.
|
||||
#### Notes:
|
||||
- Instances list in JSON format can be found in [instances.json](instances.json) file.
|
||||
- For people who might be capable and interested in hosting a public instance feel free to do so, and don't forget to open a pull request, so your instance can be included here.
|
||||
|
||||
## Contributing
|
||||
Contributions are welcome.
|
||||
|
86
data/album.go
Normal file
86
data/album.go
Normal file
@ -0,0 +1,86 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
type AlbumPreview struct {
|
||||
Name string
|
||||
Image string
|
||||
URL string
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
AlbumPreview
|
||||
Artist ArtistPreview
|
||||
About string
|
||||
|
||||
Tracks []Track
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Title string
|
||||
Url string
|
||||
Number int
|
||||
}
|
||||
|
||||
type albumMetadata struct {
|
||||
Album struct {
|
||||
Id int `json:"id"`
|
||||
Image string `json:"cover_art_thumbnail_url"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description_preview"`
|
||||
artistPreviewMetadata `json:"artist"`
|
||||
}
|
||||
AlbumAppearances []AlbumAppearances `json:"album_appearances"`
|
||||
}
|
||||
|
||||
type AlbumAppearances struct {
|
||||
Id int `json:"id"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
Song struct {
|
||||
Title string `json:"title"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
}
|
||||
|
||||
type artistPreviewMetadata struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (a *Album) parseAlbumData(doc *goquery.Document) error {
|
||||
pageMetadata, exists := doc.Find("meta[itemprop='page_data']").Attr("content")
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
var albumMetadataFromPage albumMetadata
|
||||
if err := json.Unmarshal([]byte(pageMetadata), &albumMetadataFromPage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
albumData := albumMetadataFromPage.Album
|
||||
a.Artist = ArtistPreview{
|
||||
Name: albumData.artistPreviewMetadata.Name,
|
||||
URL: utils.TrimURL(albumData.artistPreviewMetadata.URL),
|
||||
}
|
||||
a.Name = albumData.Name
|
||||
a.Image = albumData.Image
|
||||
a.About = albumData.Description
|
||||
|
||||
for _, track := range albumMetadataFromPage.AlbumAppearances {
|
||||
url := strings.Replace(track.Song.Url, "https://genius.com", "", -1)
|
||||
a.Tracks = append(a.Tracks, Track{Title: track.Song.Title, Url: url, Number: track.TrackNumber})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Album) Parse(doc *goquery.Document) error {
|
||||
return a.parseAlbumData(doc)
|
||||
}
|
15
data/annotation.go
Normal file
15
data/annotation.go
Normal file
@ -0,0 +1,15 @@
|
||||
package data
|
||||
|
||||
type AnnotationsResponse struct {
|
||||
Response struct {
|
||||
Referent struct {
|
||||
Annotations []struct {
|
||||
Body Annotation `json:"body"`
|
||||
} `json:"annotations"`
|
||||
} `json:"referent"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
type Annotation struct {
|
||||
HTML string `json:"html"`
|
||||
}
|
64
data/article.go
Normal file
64
data/article.go
Normal file
@ -0,0 +1,64 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
type Article struct {
|
||||
Title string
|
||||
Subtitle string
|
||||
HTML string
|
||||
Authors []Author
|
||||
PublishedAt time.Time
|
||||
Image string
|
||||
}
|
||||
|
||||
type Author struct {
|
||||
Name string
|
||||
Role string `json:"human_readable_role_for_display"`
|
||||
About string `json:"about_me_summary"`
|
||||
}
|
||||
|
||||
type articleResponse struct {
|
||||
Article struct {
|
||||
Title string
|
||||
Subtitle string `json:"dek"`
|
||||
Authors []Author
|
||||
Body struct {
|
||||
HTML string
|
||||
}
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
Image string `json:"preview_image"`
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Article) parseArticleData(doc *goquery.Document) error {
|
||||
pageMetadata, exists := doc.Find("meta[itemprop='page_data']").Attr("content")
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
var articleData articleResponse
|
||||
if err := json.Unmarshal([]byte(pageMetadata), &articleData); err != nil {
|
||||
return err
|
||||
}
|
||||
data := articleData.Article
|
||||
|
||||
a.Title = data.Title
|
||||
a.Subtitle = data.Subtitle
|
||||
|
||||
a.HTML = utils.CleanBody(data.Body.HTML)
|
||||
a.Authors = data.Authors
|
||||
a.PublishedAt = time.Unix(data.PublishedAt, 0)
|
||||
a.Image = ExtractImageURL(data.Image)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Article) Parse(doc *goquery.Document) error {
|
||||
return a.parseArticleData(doc)
|
||||
}
|
65
data/artist.go
Normal file
65
data/artist.go
Normal file
@ -0,0 +1,65 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
type ArtistPreview struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Name string
|
||||
Description string
|
||||
Albums []AlbumPreview
|
||||
Image string
|
||||
}
|
||||
|
||||
type artistMetadata struct {
|
||||
Artist struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description_preview"`
|
||||
Image string `json:"image_url"`
|
||||
}
|
||||
Albums []struct {
|
||||
Id int `json:"id"`
|
||||
Image string `json:"cover_art_thumbnail_url"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
} `json:"artist_albums"`
|
||||
}
|
||||
|
||||
func (a *Artist) parseArtistData(doc *goquery.Document) error {
|
||||
pageMetadata, exists := doc.Find("meta[itemprop='page_data']").Attr("content")
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
var artistMetadataFromPage artistMetadata
|
||||
if err := json.Unmarshal([]byte(pageMetadata), &artistMetadataFromPage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.Name = artistMetadataFromPage.Artist.Name
|
||||
a.Description = artistMetadataFromPage.Artist.Description
|
||||
a.Image = artistMetadataFromPage.Artist.Image
|
||||
|
||||
for _, album := range artistMetadataFromPage.Albums {
|
||||
a.Albums = append(a.Albums, AlbumPreview{
|
||||
Name: album.Name,
|
||||
Image: album.Image,
|
||||
URL: utils.TrimURL(album.URL),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Artist) Parse(doc *goquery.Document) error {
|
||||
return a.parseArtistData(doc)
|
||||
}
|
3
data/data.go
Normal file
3
data/data.go
Normal file
@ -0,0 +1,3 @@
|
||||
package data
|
||||
|
||||
var Version = "DEVELOPMENT"
|
139
data/lyrics.go
Normal file
139
data/lyrics.go
Normal file
@ -0,0 +1,139 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
type Song struct {
|
||||
Artist string
|
||||
Title string
|
||||
Image string
|
||||
Lyrics string
|
||||
Credits map[string]string
|
||||
About string
|
||||
Album AlbumPreview
|
||||
ArtistPageURL string
|
||||
}
|
||||
|
||||
type songResponse struct {
|
||||
Response struct {
|
||||
Song struct {
|
||||
ArtistNames string `json:"artist_names"`
|
||||
Image string `json:"song_art_image_thumbnail_url"`
|
||||
Title string
|
||||
Description struct {
|
||||
Plain string
|
||||
}
|
||||
Album struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"cover_art_url"`
|
||||
}
|
||||
CustomPerformances []customPerformance `json:"custom_performances"`
|
||||
WriterArtists []struct {
|
||||
Name string
|
||||
} `json:"writer_artists"`
|
||||
ProducerArtists []struct {
|
||||
Name string
|
||||
} `json:"producer_artists"`
|
||||
PrimaryArtist struct {
|
||||
URL string
|
||||
} `json:"primary_artist"`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type customPerformance struct {
|
||||
Label string
|
||||
Artists []struct {
|
||||
Name string
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Song) parseLyrics(doc *goquery.Document) error {
|
||||
var htmlError error
|
||||
|
||||
doc.Find("[data-lyrics-container='true']").Each(func(i int, ss *goquery.Selection) {
|
||||
h, err := ss.Html()
|
||||
if err != nil {
|
||||
htmlError = err
|
||||
}
|
||||
s.Lyrics += h
|
||||
})
|
||||
|
||||
if htmlError != nil {
|
||||
return htmlError
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Song) parseSongData(doc *goquery.Document) error {
|
||||
attr, exists := doc.Find("meta[property='twitter:app:url:iphone']").Attr("content")
|
||||
if exists {
|
||||
songID := strings.Replace(attr, "genius://songs/", "", 1)
|
||||
|
||||
u := fmt.Sprintf("https://genius.com/api/songs/%s?text_format=plain", songID)
|
||||
|
||||
res, err := utils.SendRequest(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
var data songResponse
|
||||
decoder := json.NewDecoder(res.Body)
|
||||
err = decoder.Decode(&data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
songData := data.Response.Song
|
||||
s.Artist = songData.ArtistNames
|
||||
s.Image = songData.Image
|
||||
s.Title = songData.Title
|
||||
s.About = songData.Description.Plain
|
||||
s.Credits = make(map[string]string)
|
||||
s.Album.Name = songData.Album.Name
|
||||
s.ArtistPageURL = utils.TrimURL(songData.PrimaryArtist.URL)
|
||||
s.Album.URL = utils.TrimURL(songData.Album.URL)
|
||||
s.Album.Image = ExtractImageURL(songData.Album.Image)
|
||||
|
||||
s.Credits["Writers"] = joinNames(songData.WriterArtists)
|
||||
s.Credits["Producers"] = joinNames(songData.ProducerArtists)
|
||||
for _, perf := range songData.CustomPerformances {
|
||||
s.Credits[perf.Label] = joinNames(perf.Artists)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinNames(data []struct {
|
||||
Name string
|
||||
},
|
||||
) string {
|
||||
var names []string
|
||||
for _, hasName := range data {
|
||||
names = append(names, hasName.Name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func (s *Song) Parse(doc *goquery.Document) error {
|
||||
if err := s.parseLyrics(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.parseSongData(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
16
data/proxy.go
Normal file
16
data/proxy.go
Normal file
@ -0,0 +1,16 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func ExtractImageURL(image string) string {
|
||||
u, err := url.Parse(image)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/images%s", u.Path)
|
||||
}
|
||||
|
33
data/search.go
Normal file
33
data/search.go
Normal file
@ -0,0 +1,33 @@
|
||||
package data
|
||||
|
||||
type SearchResponse struct {
|
||||
Response struct {
|
||||
Sections sections
|
||||
}
|
||||
}
|
||||
|
||||
type result struct {
|
||||
ArtistNames string `json:"artist_names"`
|
||||
Title string
|
||||
Path string
|
||||
Thumbnail string `json:"song_art_image_thumbnail_url"`
|
||||
ArtistImage string `json:"image_url"`
|
||||
ArtistName string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
AlbumImage string `json:"cover_art_url"`
|
||||
AlbumName string `json:"full_title"`
|
||||
}
|
||||
|
||||
type hits []struct {
|
||||
Result result
|
||||
}
|
||||
|
||||
type sections []struct {
|
||||
Type string
|
||||
Hits hits
|
||||
}
|
||||
|
||||
type SearchResults struct {
|
||||
Query string
|
||||
Sections sections
|
||||
}
|
15
go.mod
15
go.mod
@ -1,20 +1,17 @@
|
||||
module github.com/rramiachraf/dumb
|
||||
|
||||
go 1.21
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/a-h/templ v0.2.747
|
||||
github.com/allegro/bigcache/v3 v3.1.0
|
||||
github.com/caffix/cloudflare-roundtripper v0.0.0-20181218223503-4c29d231c9cb
|
||||
github.com/gorilla/handlers v1.5.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/robertkrimen/otto v0.3.0 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/sourcemap.v1 v1.0.5 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
)
|
||||
|
48
go.sum
48
go.sum
@ -1,27 +1,19 @@
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg=
|
||||
github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4=
|
||||
github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk=
|
||||
github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/caffix/cloudflare-roundtripper v0.0.0-20181218223503-4c29d231c9cb h1:RKySaWkjoE/ECY1FFk4JbcfG9dTrJmoqp08rQ2oA51Y=
|
||||
github.com/caffix/cloudflare-roundtripper v0.0.0-20181218223503-4c29d231c9cb/go.mod h1:LkIRP8n1KY5Ew4Y7S+V7ooavIXNrraFZ1IKmI4SNMuE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robertkrimen/otto v0.3.0 h1:5RI+8860NSxvXywDY9ddF5HcPw0puRsd8EgbXV0oqRE=
|
||||
github.com/robertkrimen/otto v0.3.0/go.mod h1:uW9yN1CYflmUQYvAMS0m+ZiNo3dMzRUDQJX0jWbzgxw=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
@ -29,47 +21,33 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
|
||||
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
|
71
handlers/album.go
Normal file
71
handlers/album.go
Normal file
@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
func album(l *utils.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
artist := mux.Vars(r)["artist"]
|
||||
albumName := mux.Vars(r)["albumName"]
|
||||
|
||||
id := fmt.Sprintf("%s/%s", artist, albumName)
|
||||
|
||||
if a, err := getCache[data.Album](id); err == nil {
|
||||
views.AlbumPage(a).Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://genius.com/albums/%s/%s", artist, albumName)
|
||||
|
||||
resp, err := utils.SendRequest(url)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
views.ErrorPage(404, "page not found").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "something went wrong").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
cf := doc.Find(".cloudflare_content").Length()
|
||||
if cf > 0 {
|
||||
l.Error("cloudflare got in the way")
|
||||
views.ErrorPage(500, "cloudflare is detected").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
var a data.Album
|
||||
if err = a.Parse(doc); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
|
||||
views.AlbumPage(a).Render(context.Background(), w)
|
||||
|
||||
if err = setCache(id, a); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
41
handlers/album_test.go
Normal file
41
handlers/album_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
func TestAlbum(t *testing.T) {
|
||||
url := "/albums/Daft-punk/Random-access-memories"
|
||||
title := "Give Life Back to Music"
|
||||
|
||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
l := utils.NewLogger(os.Stdout)
|
||||
m := New(l, &assets{})
|
||||
|
||||
m.ServeHTTP(rr, r)
|
||||
|
||||
defer rr.Result().Body.Close()
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(rr.Result().Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
docTitle := doc.Find("#album-tracklist > a > p").First().Text()
|
||||
|
||||
if docTitle != title {
|
||||
t.Fatalf("expected %q, got %q\n", title, docTitle)
|
||||
}
|
||||
}
|
81
handlers/annotations.go
Normal file
81
handlers/annotations.go
Normal file
@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
func annotations(l *utils.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["annotation-id"]
|
||||
if a, err := getCache[data.Annotation]("annotation:" + id); err == nil {
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
w.Header().Set("content-type", "application/json")
|
||||
if err = encoder.Encode(&a); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://genius.com/api/referents/%s?text_format=html", id)
|
||||
resp, err := utils.SendRequest(url)
|
||||
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "cannot reach genius servers").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
views.ErrorPage(404, "page not found").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = buf.ReadFrom(resp.Body)
|
||||
if err != nil {
|
||||
l.Error("Error paring genius api response: %s", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "something went wrong").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
var data data.AnnotationsResponse
|
||||
err = json.Unmarshal(buf.Bytes(), &data)
|
||||
if err != nil {
|
||||
l.Error("could not unmarshal json: %s\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "something went wrong").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
body := data.Response.Referent.Annotations[0].Body
|
||||
body.HTML = utils.CleanBody(body.HTML)
|
||||
|
||||
w.Header().Set("content-type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
if err = encoder.Encode(&body); err != nil {
|
||||
l.Error("Error sending response: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err = setCache("annotation:"+id, body); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
39
handlers/annotations_test.go
Normal file
39
handlers/annotations_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
func TestAnnotations(t *testing.T) {
|
||||
url := "/61590/Black-star-respiration/The-new-moon-rode-high-in-the-crown-of-the-metropolis/annotations"
|
||||
|
||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
l := utils.NewLogger(os.Stdout)
|
||||
m := New(l, &assets{})
|
||||
|
||||
m.ServeHTTP(rr, r)
|
||||
|
||||
defer rr.Result().Body.Close()
|
||||
|
||||
decoder := json.NewDecoder(rr.Result().Body)
|
||||
annotation := map[string]string{}
|
||||
|
||||
if err := decoder.Decode(&annotation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, exists := annotation["html"]; !exists {
|
||||
t.Fatalf("html field not found on annotation\n")
|
||||
}
|
||||
}
|
68
handlers/article.go
Normal file
68
handlers/article.go
Normal file
@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
func article(l *utils.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
articleSlug := mux.Vars(r)["article"]
|
||||
|
||||
if a, err := getCache[data.Article](articleSlug); err == nil {
|
||||
views.ArticlePage(a).Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://genius.com/a/%s", articleSlug)
|
||||
|
||||
resp, err := utils.SendRequest(url)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
views.ErrorPage(404, "page not found").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "something went wrong").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
cf := doc.Find(".cloudflare_content").Length()
|
||||
if cf > 0 {
|
||||
l.Error("cloudflare got in the way")
|
||||
views.ErrorPage(500, "cloudflare is detected").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
var a data.Article
|
||||
if err = a.Parse(doc); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
|
||||
views.ArticlePage(a).Render(context.Background(), w)
|
||||
|
||||
if err = setCache(articleSlug, a); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
55
handlers/article_test.go
Normal file
55
handlers/article_test.go
Normal file
@ -0,0 +1,55 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
func TestArticle(t *testing.T) {
|
||||
url := "/a/genius-celebrates-hip-hops-50th-anniversary-with-a-look-back-at-the-music-thats-defined-this-site"
|
||||
title := "Genius Celebrates Hip-Hop’s 50th Anniversary With A Look Back At The Music That’s Defined This Site"
|
||||
subtitle := "The first post in a yearlong look at the genre’s storied history."
|
||||
|
||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
l := utils.NewLogger(os.Stdout)
|
||||
m := New(l, &assets{})
|
||||
|
||||
m.ServeHTTP(rr, r)
|
||||
|
||||
defer rr.Result().Body.Close()
|
||||
|
||||
if rr.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected %d, got %d\n", http.StatusOK, rr.Result().StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(rr.Result().Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
articleTitle := doc.Find("#article-title").First().Text()
|
||||
if articleTitle != title {
|
||||
t.Fatalf("expected %q, got %q\n", title, articleTitle)
|
||||
}
|
||||
|
||||
articleSubtitle := doc.Find("#article-subtitle").First().Text()
|
||||
if articleSubtitle != subtitle {
|
||||
t.Fatalf("expected %q, got %q\n", subtitle, articleSubtitle)
|
||||
}
|
||||
|
||||
articleBody := doc.Find("#article-body").First().Text()
|
||||
if len(articleBody) == 0 {
|
||||
t.Fatal("missing article body\n")
|
||||
}
|
||||
}
|
70
handlers/artist.go
Normal file
70
handlers/artist.go
Normal file
@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
func artist(l *utils.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
artistName := mux.Vars(r)["artist"]
|
||||
|
||||
id := fmt.Sprintf("artist:%s", artistName)
|
||||
|
||||
if a, err := getCache[data.Artist](id); err == nil {
|
||||
views.ArtistPage(a).Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://genius.com/artists/%s", artistName)
|
||||
|
||||
resp, err := utils.SendRequest(url)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
views.ErrorPage(404, "page not found").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "something went wrong").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
cf := doc.Find(".cloudflare_content").Length()
|
||||
if cf > 0 {
|
||||
l.Error("cloudflare got in the way")
|
||||
views.ErrorPage(500, "cloudflare is detected").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
var a data.Artist
|
||||
if err = a.Parse(doc); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
|
||||
views.ArtistPage(a).Render(context.Background(), w)
|
||||
|
||||
if err = setCache(id, a); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
50
handlers/artist_test.go
Normal file
50
handlers/artist_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
func TestArtist(t *testing.T) {
|
||||
url := "/artists/Red-hot-chili-peppers"
|
||||
name := "Red Hot Chili Peppers"
|
||||
firstAlbumName := "Cardiff, Wales: 6/23/04"
|
||||
|
||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
l := utils.NewLogger(os.Stdout)
|
||||
m := New(l, &assets{})
|
||||
|
||||
m.ServeHTTP(rr, r)
|
||||
|
||||
defer rr.Result().Body.Close()
|
||||
|
||||
if rr.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected %d, got %d\n", http.StatusOK, rr.Result().StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(rr.Result().Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
artistName := doc.Find("#metadata-info > h1").First().Text()
|
||||
if artistName != name {
|
||||
t.Fatalf("expected %q, got %q\n", name, artistName)
|
||||
}
|
||||
|
||||
albumName := doc.Find("#artist-albumlist > a > p").First().Text()
|
||||
if albumName != firstAlbumName {
|
||||
t.Fatalf("expected %q, got %q\n", firstAlbumName, albumName)
|
||||
}
|
||||
}
|
40
handlers/cache.go
Normal file
40
handlers/cache.go
Normal file
@ -0,0 +1,40 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/allegro/bigcache/v3"
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
)
|
||||
|
||||
type cachable interface {
|
||||
data.Album | data.Song | data.Annotation | data.Artist | data.Article | []byte
|
||||
}
|
||||
|
||||
var c, _ = bigcache.New(context.Background(), bigcache.DefaultConfig(time.Hour*24))
|
||||
|
||||
func setCache(key string, entry interface{}) error {
|
||||
data, err := json.Marshal(&entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Set(key, data)
|
||||
}
|
||||
|
||||
func getCache[v cachable](key string) (v, error) {
|
||||
var decoded v
|
||||
|
||||
data, err := c.Get(key)
|
||||
if err != nil {
|
||||
return decoded, err
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(data, &decoded); err != nil {
|
||||
return decoded, err
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
25
handlers/cache_test.go
Normal file
25
handlers/cache_test.go
Normal file
@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
key := "testkey"
|
||||
value := []byte("testvalue")
|
||||
|
||||
err := setCache(key, value)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to set cache, %q\n", err)
|
||||
}
|
||||
|
||||
v, err := getCache[[]byte](key)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to get cache, %q\n", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(v, value) {
|
||||
t.Fatalf("expected %q, got %q\n", value, v)
|
||||
}
|
||||
}
|
76
handlers/handler.go
Normal file
76
handlers/handler.go
Normal file
@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
gorillaHandlers "github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
type route struct {
|
||||
Path string
|
||||
Handler func(*utils.Logger) http.HandlerFunc
|
||||
Method string
|
||||
Template func() templ.Component
|
||||
}
|
||||
|
||||
func New(logger *utils.Logger, staticFiles static) *mux.Router {
|
||||
r := mux.NewRouter()
|
||||
|
||||
r.Use(utils.MustHeaders)
|
||||
r.Use(gorillaHandlers.CompressHandler)
|
||||
|
||||
routes := []route{
|
||||
{Path: "/", Template: views.HomePage},
|
||||
{Path: "/robots.txt", Handler: robotsHandler},
|
||||
{Path: "/albums/{artist}/{albumName}", Handler: album},
|
||||
{Path: "/artists/{artist}", Handler: artist},
|
||||
{Path: "/a/{article}", Handler: article},
|
||||
{Path: "/images/{filename}.{ext}", Handler: imageProxy},
|
||||
{Path: "/search", Handler: search},
|
||||
{Path: "/{annotation-id}/{artist-song}/{verse}/annotations", Handler: annotations},
|
||||
{Path: "/instances.json", Handler: instances},
|
||||
}
|
||||
|
||||
registerRoutes(r, routes, logger)
|
||||
|
||||
r.PathPrefix("/static/").HandlerFunc(staticAssets(logger, staticFiles))
|
||||
r.PathPrefix("/{annotation-id}/{artist-song}-lyrics").HandlerFunc(lyrics(logger)).Methods("GET")
|
||||
r.PathPrefix("/{annotation-id}/{artist-song}").HandlerFunc(lyrics(logger)).Methods("GET")
|
||||
r.PathPrefix("/{annotation-id}").HandlerFunc(lyrics(logger)).Methods("GET")
|
||||
|
||||
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
views.ErrorPage(404, "page not found").Render(context.Background(), w)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func robotsHandler(l *utils.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := w.Write([]byte("User-agent: *\nDisallow: /\n")); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerRoutes(router *mux.Router, routes []route, logger *utils.Logger) {
|
||||
for _, r := range routes {
|
||||
method := r.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
|
||||
if r.Template != nil {
|
||||
router.Handle(r.Path, templ.Handler(r.Template())).Methods(method)
|
||||
continue
|
||||
}
|
||||
|
||||
router.HandleFunc(r.Path, r.Handler(logger)).Methods(method)
|
||||
}
|
||||
}
|
13
handlers/handler_test.go
Normal file
13
handlers/handler_test.go
Normal file
@ -0,0 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
type assets struct{}
|
||||
|
||||
func (assets) Open(p string) (fs.File, error) {
|
||||
return os.Open(path.Join("../", p))
|
||||
}
|
56
handlers/instances.go
Normal file
56
handlers/instances.go
Normal file
@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
const ContentTypeJSON = "application/json"
|
||||
|
||||
// TODO: move this to utils, so it can be used by other handlers.
|
||||
func sendError(err error, status int, msg string, l *utils.Logger, w http.ResponseWriter) {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(status)
|
||||
if err := views.ErrorPage(status, msg).Render(context.Background(), w); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func instances(l *utils.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if instances, err := getCache[[]byte]("instances"); err == nil {
|
||||
w.Header().Set("content-type", ContentTypeJSON)
|
||||
_, err = w.Write(instances)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res, err := utils.SendRequest("https://raw.githubusercontent.com/rramiachraf/dumb/main/instances.json")
|
||||
if err != nil {
|
||||
sendError(err, http.StatusInternalServerError, "something went wrong", l, w)
|
||||
return
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
instances, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
sendError(err, http.StatusInternalServerError, "something went wrong", l, w)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("content-type", ContentTypeJSON)
|
||||
if _, err = w.Write(instances); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
|
||||
if err = setCache("instances", instances); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
41
handlers/instances_test.go
Normal file
41
handlers/instances_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
func TestInstancesList(t *testing.T) {
|
||||
r, err := http.NewRequest(http.MethodGet, "/instances.json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
l := utils.NewLogger(os.Stdout)
|
||||
|
||||
m := New(l, &assets{})
|
||||
m.ServeHTTP(rr, r)
|
||||
|
||||
c := rr.Result().Header.Get("content-type")
|
||||
if c != ContentTypeJSON {
|
||||
t.Fatalf("expected %q, got %q", ContentTypeJSON, c)
|
||||
}
|
||||
|
||||
defer rr.Result().Body.Close()
|
||||
|
||||
d := json.NewDecoder(rr.Result().Body)
|
||||
instances := []map[string]any{}
|
||||
if err := d.Decode(&instances); err != nil {
|
||||
t.Fatalf("unable to decode json from response, %q\n", err)
|
||||
}
|
||||
|
||||
if _, exists := instances[0]["clearnet"]; !exists {
|
||||
t.Fatal("unable to get clearnet value from instances list")
|
||||
}
|
||||
}
|
72
handlers/lyrics.go
Normal file
72
handlers/lyrics.go
Normal file
@ -0,0 +1,72 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
func lyrics(l *utils.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// prefer artist-song over annotation-id for cache key when available
|
||||
id := mux.Vars(r)["artist-song"]
|
||||
if id == "" {
|
||||
id = mux.Vars(r)["annotation-id"]
|
||||
} else {
|
||||
id = id + "-lyrics"
|
||||
}
|
||||
|
||||
if s, err := getCache[data.Song](id); err == nil {
|
||||
views.LyricsPage(s).Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://genius.com/%s", id)
|
||||
resp, err := utils.SendRequest(url)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
views.ErrorPage(404, "page not found").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "something went wrong").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
cf := doc.Find(".cloudflare_content").Length()
|
||||
if cf > 0 {
|
||||
l.Error("cloudflare got in the way")
|
||||
views.ErrorPage(500, "cloudflare is detected").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
var s data.Song
|
||||
if err := s.Parse(doc); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
|
||||
views.LyricsPage(s).Render(context.Background(), w)
|
||||
if err = setCache(id, s); err != nil {
|
||||
l.Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
57
handlers/lyrics_test.go
Normal file
57
handlers/lyrics_test.go
Normal file
@ -0,0 +1,57 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
func TestLyrics(t *testing.T) {
|
||||
urls := []string{"/The-silver-seas-catch-yer-own-train-lyrics",
|
||||
"/1784308/The-silver-seas-catch-yer-own-train",
|
||||
"/1784308/The-silver-seas-catch-yer-own-train-lyrics",
|
||||
"/1784308/The-silver-seas-catch-yer-own-train/Baby-you-and-i-are-not-the-same-you-say-you-like-sun-i-like-the-rain",
|
||||
"/1784308/The-silver-seas-catch-yer-own-train-lyrics/Baby-you-and-i-are-not-the-same-you-say-you-like-sun-i-like-the-rain",
|
||||
"/1784308"}
|
||||
for _, url := range urls {
|
||||
t.Run(url, func(t *testing.T) { testLyrics(t, url) })
|
||||
}
|
||||
}
|
||||
|
||||
func testLyrics(t *testing.T, url string) {
|
||||
title := "The Silver Seas"
|
||||
artist := "Catch Yer Own Train"
|
||||
|
||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
l := utils.NewLogger(os.Stdout)
|
||||
m := New(l, &assets{})
|
||||
|
||||
m.ServeHTTP(rr, r)
|
||||
|
||||
defer rr.Result().Body.Close()
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(rr.Result().Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
docArtist := doc.Find("#metadata-info h1").Text()
|
||||
docTitle := doc.Find("#metadata-info h2").Text()
|
||||
|
||||
if docTitle != title {
|
||||
t.Fatalf("expected %q, got %q\n", title, docTitle)
|
||||
}
|
||||
|
||||
if docArtist != artist {
|
||||
t.Fatalf("expected %q, got %q\n", artist, docArtist)
|
||||
}
|
||||
}
|
65
handlers/proxy.go
Normal file
65
handlers/proxy.go
Normal file
@ -0,0 +1,65 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
func isValidExt(ext string) bool {
|
||||
extType := mime.TypeByExtension("." + strings.ToLower(ext))
|
||||
isImage, _, found := strings.Cut(extType, "/")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
|
||||
if isImage == "image" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func imageProxy(l *utils.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
v := mux.Vars(r)
|
||||
f := v["filename"]
|
||||
ext := v["ext"]
|
||||
|
||||
if !isValidExt(ext) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
views.ErrorPage(400, "something went wrong").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
// first segment of URL resize the image to reduce bandwith usage.
|
||||
url := fmt.Sprintf("https://t2.genius.com/unsafe/300x300/https://images.genius.com/%s.%s", f, ext)
|
||||
|
||||
res, err := utils.SendRequest(url)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "something went wrong").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-type", mime.TypeByExtension("."+ext))
|
||||
w.Header().Add("Cache-Control", "max-age=1296000")
|
||||
if _, err = io.Copy(w, res.Body); err != nil {
|
||||
l.Error("unable to write image, %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
39
handlers/proxy_test.go
Normal file
39
handlers/proxy_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
func TestImageProxy(t *testing.T) {
|
||||
imgURL := "/images/3852401ae6c6d6a51aafe814d67199f0.1000x1000x1.jpg"
|
||||
|
||||
r, err := http.NewRequest(http.MethodGet, imgURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
l := utils.NewLogger(os.Stdout)
|
||||
m := New(l, &assets{})
|
||||
|
||||
m.ServeHTTP(rr, r)
|
||||
|
||||
cc := rr.Result().Header.Get("cache-control")
|
||||
maxAge := "max-age=1296000"
|
||||
ct := rr.Result().Header.Get("content-type")
|
||||
mimeType := mime.TypeByExtension(".jpg")
|
||||
|
||||
if cc != maxAge {
|
||||
t.Fatalf("expected %q, got %q\n", maxAge, cc)
|
||||
}
|
||||
|
||||
if ct != mimeType {
|
||||
t.Fatalf("expected %q, got %q\n", mimeType, ct)
|
||||
}
|
||||
}
|
44
handlers/search.go
Normal file
44
handlers/search.go
Normal file
@ -0,0 +1,44 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
func search(l *utils.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
url := fmt.Sprintf(`https://genius.com/api/search/multi?q=%s`, url.QueryEscape(query))
|
||||
|
||||
res, err := utils.SendRequest(url)
|
||||
if err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w)
|
||||
return
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
var sRes data.SearchResponse
|
||||
|
||||
d := json.NewDecoder(res.Body)
|
||||
if err = d.Decode(&sRes); err != nil {
|
||||
l.Error(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
views.ErrorPage(500, "something went wrong").Render(context.Background(), w)
|
||||
}
|
||||
|
||||
results := data.SearchResults{Query: query, Sections: sRes.Response.Sections}
|
||||
|
||||
views.SearchPage(results).Render(context.Background(), w)
|
||||
}
|
||||
|
||||
}
|
39
handlers/search_test.go
Normal file
39
handlers/search_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
url := "/search?q=it+aint+hard+to+tell"
|
||||
artist := "Nas"
|
||||
|
||||
r, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
l := utils.NewLogger(os.Stdout)
|
||||
m := New(l, &assets{})
|
||||
|
||||
m.ServeHTTP(rr, r)
|
||||
|
||||
defer rr.Result().Body.Close()
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(rr.Result().Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
docArtist := doc.Find("#search-item > div > span").First().Text()
|
||||
if docArtist != artist {
|
||||
t.Fatalf("expected %q, got %q\n", artist, docArtist)
|
||||
}
|
||||
}
|
38
handlers/static.go
Normal file
38
handlers/static.go
Normal file
@ -0,0 +1,38 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
"github.com/rramiachraf/dumb/views"
|
||||
)
|
||||
|
||||
type static interface {
|
||||
Open(string) (fs.File, error)
|
||||
}
|
||||
|
||||
func staticAssets(logger *utils.Logger, embededFiles static) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
url := strings.Replace(r.URL.Path, "/static", "static", 1)
|
||||
f, err := embededFiles.Open(url)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
views.ErrorPage(http.StatusNotFound, "page not found")
|
||||
return
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
mimeType := mime.TypeByExtension(path.Ext(r.URL.Path))
|
||||
w.Header().Set("content-type", mimeType)
|
||||
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
35
handlers/static_test.go
Normal file
35
handlers/static_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
func TestStaticAssets(t *testing.T) {
|
||||
r, err := http.NewRequest(http.MethodGet, "/static/style.css", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
l := utils.NewLogger(os.Stdout)
|
||||
m := New(l, &assets{})
|
||||
|
||||
m.ServeHTTP(rr, r)
|
||||
|
||||
contentType := rr.Header().Get("content-type")
|
||||
expectedContentType := mime.TypeByExtension(".css")
|
||||
|
||||
if contentType != expectedContentType {
|
||||
t.Fatalf("expected %q, got %q", expectedContentType, contentType)
|
||||
}
|
||||
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("expected %d, got %d", 200, rr.Code)
|
||||
}
|
||||
}
|
35
instances.json
Normal file
35
instances.json
Normal file
@ -0,0 +1,35 @@
|
||||
[
|
||||
{
|
||||
"clearnet": "https://dm.vern.cc/",
|
||||
"tor": "http://dm.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion/",
|
||||
"i2p": "http://vernxpcpqi2y4uhu7to4rnjmyjjgzh3x3qxyzpmkhykefchkmleq.b32.i2p/",
|
||||
"country": "US",
|
||||
"cdn": false
|
||||
},
|
||||
{
|
||||
"clearnet": "https://sing.whatever.social/",
|
||||
"country": "US/DE",
|
||||
"cdn": true
|
||||
},
|
||||
{
|
||||
"clearnet": "https://dumb.lunar.icu/",
|
||||
"country": "DE",
|
||||
"cdn": true
|
||||
},
|
||||
{
|
||||
"clearnet": "https://dumb.privacydev.net/",
|
||||
"tor": "http://dumb.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion/",
|
||||
"country": "FR",
|
||||
"cdn": false
|
||||
},
|
||||
{
|
||||
"clearnet": "https://dumb.ducks.party/",
|
||||
"country": "NL",
|
||||
"cdn": false
|
||||
},
|
||||
{
|
||||
"clearnet": "https://dumb.privacyfucking.rocks/",
|
||||
"country": "DE",
|
||||
"cdn": false
|
||||
}
|
||||
]
|
37
main.go
37
main.go
@ -1,22 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
<<<<<<< HEAD
|
||||
"context"
|
||||
=======
|
||||
"embed"
|
||||
>>>>>>> upstream/main
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/allegro/bigcache/v3"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/rramiachraf/dumb/handlers"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
var logger = logrus.New()
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
func main() {
|
||||
<<<<<<< HEAD
|
||||
ctx := context.Background()
|
||||
c, err := bigcache.New(ctx, bigcache.DefaultConfig(time.Hour*24))
|
||||
if err != nil {
|
||||
@ -43,25 +49,40 @@ func main() {
|
||||
})
|
||||
|
||||
})
|
||||
=======
|
||||
logger := utils.NewLogger(os.Stdout)
|
||||
>>>>>>> upstream/main
|
||||
|
||||
server := &http.Server{
|
||||
Handler: r,
|
||||
Handler: handlers.New(logger, staticFiles),
|
||||
WriteTimeout: 25 * time.Second,
|
||||
ReadTimeout: 25 * time.Second,
|
||||
}
|
||||
|
||||
PROXY_ENV := os.Getenv("PROXY")
|
||||
if PROXY_ENV != "" {
|
||||
if _, err := url.ParseRequestURI(PROXY_ENV); err != nil {
|
||||
logger.Fatal("invalid proxy")
|
||||
}
|
||||
|
||||
logger.Info("using a custom proxy for requests")
|
||||
}
|
||||
|
||||
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||
|
||||
if port == 0 {
|
||||
port = 5555
|
||||
logger.Info("using default port %d", port)
|
||||
}
|
||||
|
||||
l, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
logger.Fatalln(err)
|
||||
logger.Fatal(err.Error())
|
||||
}
|
||||
|
||||
logger.Infof("server is listening on port %d\n", port)
|
||||
logger.Info("server is listening on port %d", port)
|
||||
|
||||
logger.Fatalln(server.Serve(l))
|
||||
if err := server.Serve(l); err != nil {
|
||||
logger.Fatal(err.Error())
|
||||
}
|
||||
}
|
||||
|
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 402 KiB |
@ -1,21 +0,0 @@
|
||||
[Unit]
|
||||
Description=ListMonk
|
||||
Documentation=https://github.com/rramiachraf/dumb
|
||||
After=system.slice multi-user.target postgresql.service network.target
|
||||
|
||||
[Service]
|
||||
User=git
|
||||
Type=simple
|
||||
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=listmonk
|
||||
|
||||
WorkingDirectory=/etc/dumb
|
||||
ExecStart=/etc/dumb/dumb
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -1,35 +0,0 @@
|
||||
server {
|
||||
# root /var/www/dumb.yoursite.com/html;
|
||||
# index index.html index.htm index.nginx-debian.html;
|
||||
|
||||
server_name dumb.yoursite.com;
|
||||
# www.dumb.yoursite.com;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
proxy_pass http://localhost:5555;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/dumb.yoursite.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/dumb.yoursite.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
server {
|
||||
if ($host = dumb.yoursite.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
server_name dumb.yoursite.com;
|
||||
|
||||
listen 80;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
}
|
||||
|
@ -1,21 +1,34 @@
|
||||
const fullAbout = document.querySelector("#about #full_about")
|
||||
const summary = document.querySelector("#about #summary")
|
||||
const description = document.querySelector("#description > #full")
|
||||
const summary = document.querySelector("#description > #summary")
|
||||
|
||||
function showAbout() {
|
||||
function showDescription() {
|
||||
summary.classList.toggle("hidden")
|
||||
fullAbout.classList.toggle("hidden")
|
||||
description.classList.toggle("hidden")
|
||||
}
|
||||
|
||||
[fullAbout, summary].forEach(item => item.onclick = showAbout)
|
||||
description && [description, summary].forEach(item => item.onclick = showDescription)
|
||||
|
||||
document.querySelectorAll("#lyrics a").forEach(item => {
|
||||
window.addEventListener("load", () => {
|
||||
const geniusURL = "https://genius.com" + document.location.pathname + document.location.search
|
||||
document.getElementById("goto-genius").setAttribute("href", geniusURL)
|
||||
document.querySelectorAll("#lyrics a").forEach(item => {
|
||||
item.addEventListener("click", getAnnotation)
|
||||
})
|
||||
|
||||
const linkedAnnotationId = window.location.pathname.match(new RegExp("/(\\d+)"))?.[1]
|
||||
if (linkedAnnotationId) {
|
||||
const target = document.querySelector(`a[href^="/${linkedAnnotationId}"][class^="ReferentFragmentdesktop__ClickTarget"] > span`)
|
||||
target?.click()
|
||||
target?.scrollIntoView()
|
||||
}
|
||||
})
|
||||
|
||||
function getAnnotation(e) {
|
||||
e.preventDefault()
|
||||
const uri = e.target.parentElement.getAttribute("href")
|
||||
const presentAnnotation = document.getElementById(uri)
|
||||
//document.querySelector('.annotation')?.remove()
|
||||
const link = e.currentTarget
|
||||
const uri = link.getAttribute("href")
|
||||
const presentAnnotation = link.nextElementSibling.matches(".annotation") && link.nextElementSibling
|
||||
if (presentAnnotation) {
|
||||
presentAnnotation.remove()
|
||||
return
|
||||
@ -31,7 +44,40 @@ function getAnnotation(e) {
|
||||
annotationDiv.innerHTML = parsedReponse.html
|
||||
annotationDiv.id = uri
|
||||
annotationDiv.className = "annotation"
|
||||
e.target.parentElement.insertAdjacentElement('afterend', annotationDiv)
|
||||
if (!link.nextElementSibling.matches(".annotation")) {
|
||||
link.insertAdjacentElement('afterend', annotationDiv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window._currentTheme = localStorage.getItem("_theme") || "light"
|
||||
setTheme(window._currentTheme)
|
||||
|
||||
const themeChooser = document.getElementById("choose-theme")
|
||||
themeChooser.addEventListener("click", function() {
|
||||
if (window._currentTheme === "dark") {
|
||||
setTheme("light")
|
||||
} else {
|
||||
setTheme("dark")
|
||||
}
|
||||
})
|
||||
|
||||
function setTheme(theme) {
|
||||
const toggler = document.getElementById("ic_fluent_dark_theme_24_regular")
|
||||
switch (theme) {
|
||||
case "dark":
|
||||
toggler.setAttribute("fill", "#fff")
|
||||
localStorage.setItem("_theme", "dark")
|
||||
document.body.classList.add("dark")
|
||||
window._currentTheme = "dark"
|
||||
return
|
||||
case "light":
|
||||
toggler.setAttribute("fill", "#181d31")
|
||||
localStorage.setItem("_theme", "light")
|
||||
document.body.classList.remove("dark")
|
||||
window._currentTheme = "light"
|
||||
return
|
||||
|
||||
}
|
||||
}
|
||||
|
406
static/style.css
406
static/style.css
@ -1,406 +0,0 @@
|
||||
/* inter-regular - cyrillic_greek_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+ */
|
||||
url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff') format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* inter-500 - cyrillic_greek_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+ */
|
||||
url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff') format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* inter-700 - cyrillic_greek_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+ */
|
||||
url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff') format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 62.5%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 1.5rem;
|
||||
font-family: inter;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
#lyrics {
|
||||
color: #171717;
|
||||
line-height: 2.5rem;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#lyrics a {
|
||||
color: inherit;
|
||||
cursor: initial;
|
||||
}
|
||||
|
||||
#lyrics a, .annotation {
|
||||
background-color: #ddd;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#lyrics a:hover {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
nav {
|
||||
background-color: #ffcd38;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
nav img {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
#metadata h1 {
|
||||
font-size: 2rem;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
#metadata h2 {
|
||||
font-size: 1.4rem;
|
||||
color: #1e1e1e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#album-artwork {
|
||||
width: 20rem;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 1px #ddd;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
#container {
|
||||
display: flex;
|
||||
padding: 5rem 10rem;
|
||||
gap: 5rem;
|
||||
}
|
||||
|
||||
#credits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#title {
|
||||
font-size: 2rem;
|
||||
color: #1b1a17;
|
||||
}
|
||||
|
||||
#credits summary {
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
#album-tracklist{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#credits p {
|
||||
font-size: 1.3rem;
|
||||
padding: 0.5rem;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
#info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
#about {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#about p {
|
||||
font-size: 1.4rem;
|
||||
color: #171717;
|
||||
line-height: 1.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.annotation {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.annotation img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.annotation ul {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#home div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#home h1 {
|
||||
font-weight: 600;
|
||||
font-size: 2.2rem;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
#home p {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#home code {
|
||||
background-color: #eee;
|
||||
padding: 0.3rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
#container {
|
||||
padding: 3rem 2rem;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
#metadata {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
background-color: #ffcd38;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
font-weight: 500;
|
||||
color: #1b1a17;
|
||||
transition: 0.3s ease text-decoration;
|
||||
font-size: 1.4rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#error h1 {
|
||||
font-size: 5rem;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
#error p {
|
||||
text-transform: uppercase;
|
||||
font-size: 1.6rem;
|
||||
color: #222;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#search-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
#search-page {
|
||||
width: 80rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 2rem;
|
||||
box-sizing: border-box;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ddd;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
#search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#search-results h1 {
|
||||
text-align:center;
|
||||
color: #111;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
#search-item {
|
||||
display: flex;
|
||||
height: 8rem;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 1px #ddd;
|
||||
}
|
||||
|
||||
#search-item h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
#search-item span {
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#search-item img {
|
||||
width: 8rem;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #181d31;
|
||||
}
|
||||
|
||||
nav,
|
||||
footer {
|
||||
background-color: #fec260;
|
||||
}
|
||||
|
||||
#lyrics {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
#lyrics a, .annotation {
|
||||
background-color: #272d44;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
#lyrics a:hover {
|
||||
background-color: #32384f;
|
||||
}
|
||||
|
||||
#metadata h1 {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
#metadata h2,
|
||||
#credits p {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
#title {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
#about p,
|
||||
#credits summary {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
#home h1, #error h1 {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
#home p, #error p{
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
#search-page h1 {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
#search-item {
|
||||
border: 1px solid #888;
|
||||
}
|
||||
|
||||
#search-item h2 {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
#search-item span {
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
44
style/album.css
Normal file
44
style/album.css
Normal file
@ -0,0 +1,44 @@
|
||||
#album-artwork {
|
||||
width: 24rem;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 1px #ddd;
|
||||
}
|
||||
|
||||
#album-tracklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#album-single-track {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#album-single-track p {
|
||||
color: #181d31;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark #album-single-track p {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
#album-single-track small {
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dark #album-single-track small {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
#album-single-track:hover p {
|
||||
text-decoration: underline;
|
||||
}
|
33
style/annotation.css
Normal file
33
style/annotation.css
Normal file
@ -0,0 +1,33 @@
|
||||
#iframed-link {
|
||||
font-weight: 500;
|
||||
background-color: #ffcd38;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.annotation, blockquote {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
background: #eee;
|
||||
border: 1px solid #ddd;
|
||||
color: #222;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.annotation img, blockquote img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.annotation a, blockquote a {
|
||||
background: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.annotation ul, blockquote ul {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
.dark .annotation, .dark blockquote {
|
||||
background-color: #272d44;
|
||||
color: inherit;
|
||||
}
|
60
style/article.css
Normal file
60
style/article.css
Normal file
@ -0,0 +1,60 @@
|
||||
#article-metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#article-body {
|
||||
line-height: 1.75;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
#article-subtitle {
|
||||
font-size: 1.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#article-image {
|
||||
width: 100%;
|
||||
height: 50rem;
|
||||
border-radius: 5px;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
background-color: #f7f7f7;
|
||||
border: 1px solid #e4e4e4;
|
||||
}
|
||||
|
||||
#metadata,
|
||||
#article-subtitle,
|
||||
#article-date {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#article-authors {
|
||||
color: #1e1e1e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dark #article-image {
|
||||
background-color: #151515;
|
||||
border: 1px solid #2f2f2f;
|
||||
}
|
||||
|
||||
.dark #metadata,
|
||||
.dark #article-subtitle,
|
||||
.dark #article-date,
|
||||
.dark #article-authors {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.dark #article-title {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.dark #article-body {
|
||||
color: #eee;
|
||||
}
|
72
style/artist.css
Normal file
72
style/artist.css
Normal file
@ -0,0 +1,72 @@
|
||||
#artist-albumlist {
|
||||
color: #181d31;
|
||||
font-weight: 500;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 150px);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
#artwork-preview {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#artist-image {
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ddd;
|
||||
}
|
||||
|
||||
#artist-name {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#artist-single-album {
|
||||
color: #111;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark #artist-albumlist p {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
#artist-albumlist small {
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dark #artist-albumlist small {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
#metadata p {
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.dark #metadata p {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
#artist-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#artist-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
#artist-section h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.dark #artist-section h2 {
|
||||
color: #ddd;
|
||||
}
|
29
style/error.css
Normal file
29
style/error.css
Normal file
@ -0,0 +1,29 @@
|
||||
#error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#error h1 {
|
||||
font-size: 5rem;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
#error p {
|
||||
text-transform: uppercase;
|
||||
font-size: 1.6rem;
|
||||
color: #222;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark #error h1 {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.dark #error p {
|
||||
color: #ddd;
|
||||
}
|
46
style/footer.css
Normal file
46
style/footer.css
Normal file
@ -0,0 +1,46 @@
|
||||
footer {
|
||||
background-color: #ffcd38;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
footer a {
|
||||
font-weight: 500;
|
||||
color: #1b1a17;
|
||||
transition: 0.3s ease text-decoration;
|
||||
font-size: 1.4rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#footer-container {
|
||||
width: 1024px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#footer-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#version {
|
||||
font-size: 1.3rem;
|
||||
color: #1b1b1b;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1080px) {
|
||||
#footer-container {
|
||||
width: 100%;
|
||||
padding: 0 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.dark footer {
|
||||
background-color: #fec260;
|
||||
}
|
50
style/home.css
Normal file
50
style/home.css
Normal file
@ -0,0 +1,50 @@
|
||||
#home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#home div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#home h1 {
|
||||
font-weight: 600;
|
||||
font-size: 2.2rem;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
#home p {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#home code {
|
||||
background-color: #eee;
|
||||
padding: 0.3rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 2rem;
|
||||
box-sizing: border-box;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ddd;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.dark #home h1 {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.dark #home p {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.dark #search-input {
|
||||
background-color: #ddd;
|
||||
}
|
42
style/layout.css
Normal file
42
style/layout.css
Normal file
@ -0,0 +1,42 @@
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#container {
|
||||
padding: 5rem 0;
|
||||
width: 1024px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.solo {
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.trio-split {
|
||||
grid-template-columns: 24rem calc(1024px - 56rem) 24rem;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.duo-split {
|
||||
grid-template-columns: 24rem 1fr;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1080px) {
|
||||
#container {
|
||||
padding: 3rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
width: calc(100vw - 4rem);
|
||||
;
|
||||
}
|
||||
}
|
134
style/lyrics.css
Normal file
134
style/lyrics.css
Normal file
@ -0,0 +1,134 @@
|
||||
#lyrics {
|
||||
color: #171717;
|
||||
line-height: 2.5rem;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#lyrics a {
|
||||
color: inherit;
|
||||
background-color: #ddd;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#lyrics a:hover {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
#metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
#metadata h1 {
|
||||
font-size: 2rem;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
#metadata h2 {
|
||||
font-size: 1.4rem;
|
||||
color: #1e1e1e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#metadata-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
#description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#description p {
|
||||
font-size: 1.4rem;
|
||||
color: #171717;
|
||||
line-height: 1.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#title {
|
||||
font-size: 2rem;
|
||||
color: #1b1a17;
|
||||
}
|
||||
|
||||
|
||||
#lyrics-album-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#credits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
#credits summary {
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
#credits p {
|
||||
font-size: 1.3rem;
|
||||
padding: 0.5rem;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dark #lyrics {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.dark #lyrics a {
|
||||
background-color: #272d44;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dark #lyrics a:hover {
|
||||
background-color: #32384f;
|
||||
}
|
||||
|
||||
.dark #metadata h1 {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.dark #metadata h2,
|
||||
.dark #credits p {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.dark #title {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.dark #description p,
|
||||
.dark #credits summary {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1080px) {
|
||||
#metadata {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
66
style/main.css
Normal file
66
style/main.css
Normal file
@ -0,0 +1,66 @@
|
||||
/* inter-regular - cyrillic_greek_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff2') format('woff2'),
|
||||
/* Chrome 36+, Opera 23+, Firefox 39+ */
|
||||
url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff') format('woff');
|
||||
/* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* inter-500 - cyrillic_greek_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff2') format('woff2'),
|
||||
/* Chrome 36+, Opera 23+, Firefox 39+ */
|
||||
url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff') format('woff');
|
||||
/* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* inter-700 - cyrillic_greek_latin_vietnamese */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff2') format('woff2'),
|
||||
/* Chrome 36+, Opera 23+, Firefox 39+ */
|
||||
url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff') format('woff');
|
||||
/* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 62.5%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 1.5rem;
|
||||
font-family: inter;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body.dark {
|
||||
background-color: #181d31;
|
||||
}
|
44
style/navbar.css
Normal file
44
style/navbar.css
Normal file
@ -0,0 +1,44 @@
|
||||
nav {
|
||||
background-color: #ffcd38;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
#nav-container {
|
||||
width: 1024px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1080px) {
|
||||
#nav-container {
|
||||
width: 100%;
|
||||
padding: 0 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
nav img {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
#nav-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dark nav {
|
||||
background-color: #fec260;
|
||||
}
|
81
style/search.css
Normal file
81
style/search.css
Normal file
@ -0,0 +1,81 @@
|
||||
#search-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
#search-page {
|
||||
width: 80rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
#search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
#search-results h2 {
|
||||
color: #222;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#search-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#search-item {
|
||||
display: flex;
|
||||
height: 8rem;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 1px #ddd;
|
||||
}
|
||||
|
||||
#search-item h3 {
|
||||
font-size: 1.8rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#search-item span {
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#search-item img {
|
||||
width: 8rem;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 2rem;
|
||||
box-sizing: border-box;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ddd;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.dark #search-page h2 {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.dark #search-item {
|
||||
border: 1px solid #888;
|
||||
}
|
||||
|
||||
.dark #search-item h3 {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.dark #search-item span {
|
||||
color: #bbb;
|
||||
}
|
122
utils.go
122
utils.go
@ -1,122 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/allegro/bigcache/v3"
|
||||
"github.com/caffix/cloudflare-roundtripper/cfrt"
|
||||
)
|
||||
|
||||
var cache *bigcache.BigCache
|
||||
|
||||
func setCache(key string, entry interface{}) error {
|
||||
data, err := json.Marshal(&entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return cache.Set(key, data)
|
||||
}
|
||||
|
||||
func getCache(key string) (interface{}, error) {
|
||||
data, err := cache.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var decoded interface{}
|
||||
|
||||
if err = json.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func write(w http.ResponseWriter, status int, data []byte) {
|
||||
w.WriteHeader(status)
|
||||
_, err := w.Write(data)
|
||||
if err != nil {
|
||||
logger.Errorln(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
csp := "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'"
|
||||
w.Header().Add("content-security-policy", csp)
|
||||
w.Header().Add("referrer-policy", "no-referrer")
|
||||
w.Header().Add("x-content-type-options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func getTemplates(templates ...string) []string {
|
||||
var pths []string
|
||||
for _, t := range templates {
|
||||
tmpl := path.Join("views", fmt.Sprintf("%s.tmpl", t))
|
||||
pths = append(pths, tmpl)
|
||||
}
|
||||
return pths
|
||||
}
|
||||
|
||||
func render(n string, w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("content-type", "text/html")
|
||||
t := template.New(n + ".tmpl").Funcs(template.FuncMap{"extractURL": extractURL})
|
||||
t, err := t.ParseFiles(getTemplates(n, "navbar", "footer")...)
|
||||
if err != nil {
|
||||
logger.Errorln(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = t.Execute(w, data); err != nil {
|
||||
logger.Errorln(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"
|
||||
|
||||
var client = &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 15 * time.Second,
|
||||
KeepAlive: 15 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
},
|
||||
}
|
||||
|
||||
func sendRequest(u string) (*http.Response, error) {
|
||||
url, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Transport, err = cfrt.New(client.Transport)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := &http.Request{
|
||||
Method: http.MethodGet,
|
||||
URL: url,
|
||||
Header: map[string][]string{
|
||||
"Accept-Language": {"en-US"},
|
||||
"User-Agent": {UA},
|
||||
},
|
||||
}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
37
utils/clean_body.go
Normal file
37
utils/clean_body.go
Normal file
@ -0,0 +1,37 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func CleanBody(body string) string {
|
||||
if doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)); err == nil {
|
||||
doc.Find("iframe").Each(func(i int, s *goquery.Selection) {
|
||||
src, exists := s.Attr("src")
|
||||
if exists {
|
||||
html := fmt.Sprintf(`<a id="iframed-link" href="%s">Link</a>`, src)
|
||||
s.ReplaceWithHtml(html)
|
||||
}
|
||||
})
|
||||
|
||||
doc.Find("img").Each(func(i int, s *goquery.Selection) {
|
||||
src, exists := s.Attr("src")
|
||||
if exists {
|
||||
re := regexp.MustCompile(`(?i)https:\/\/images\.(rapgenius|genius)\.com\/(images\/)?`)
|
||||
pSrc := re.ReplaceAllString(src, "/images/")
|
||||
s.SetAttr("src", pSrc)
|
||||
}
|
||||
})
|
||||
|
||||
if source, err := doc.Html(); err == nil {
|
||||
body = source
|
||||
}
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`https?:\/\/[a-z]*.?genius.com`)
|
||||
return re.ReplaceAllString(body, "")
|
||||
}
|
9
utils/description.go
Normal file
9
utils/description.go
Normal file
@ -0,0 +1,9 @@
|
||||
package utils
|
||||
|
||||
func TrimText(text string, keep int) string {
|
||||
if len(text) > keep {
|
||||
return text[0:keep] + "..."
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
32
utils/logger.go
Normal file
32
utils/logger.go
Normal file
@ -0,0 +1,32 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
slog *slog.Logger
|
||||
}
|
||||
|
||||
func NewLogger(w io.WriteCloser) *Logger {
|
||||
handler := slog.NewTextHandler(w, &slog.HandlerOptions{})
|
||||
sl := slog.New(handler)
|
||||
|
||||
return &Logger{slog: sl}
|
||||
}
|
||||
|
||||
func (l *Logger) Error(f string, args ...any) {
|
||||
l.slog.Error(fmt.Sprintf(f, args...))
|
||||
}
|
||||
|
||||
func (l *Logger) Info(f string, args ...any) {
|
||||
l.slog.Info(fmt.Sprintf(f, args...))
|
||||
}
|
||||
|
||||
func (l *Logger) Fatal(f string, args ...any) {
|
||||
l.Error(f, args...)
|
||||
os.Exit(1)
|
||||
}
|
58
utils/request.go
Normal file
58
utils/request.go
Normal file
@ -0,0 +1,58 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func MustHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
csp := "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'"
|
||||
w.Header().Add("content-security-policy", csp)
|
||||
w.Header().Add("referrer-policy", "no-referrer")
|
||||
w.Header().Add("x-content-type-options", "nosniff")
|
||||
w.Header().Add("content-type", "text/html")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
const UA = "Mozilla/5.0 (Windows NT 10.0; rv:123.0) Gecko/20100101 Firefox/123.0"
|
||||
|
||||
func SendRequest(u string) (*http.Response, error) {
|
||||
proxy := os.Getenv("PROXY")
|
||||
url, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
}
|
||||
|
||||
if proxy != "" {
|
||||
proxyURL, err := url.Parse(proxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse proxy url: %s", err.Error())
|
||||
}
|
||||
|
||||
client.Timeout = time.Minute * 1
|
||||
client.Transport = &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("user-agent", UA)
|
||||
|
||||
req := &http.Request{
|
||||
Method: http.MethodGet,
|
||||
URL: url,
|
||||
Header: headers,
|
||||
}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
32
utils/request_test.go
Normal file
32
utils/request_test.go
Normal file
@ -0,0 +1,32 @@
|
||||
package utils
|
||||
|
||||
/*
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendRequest(t *testing.T) {
|
||||
res, err := SendRequest("https://tls.peet.ws/api/clean")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
type fingerprint struct {
|
||||
JA3 string `json:"ja3"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(res.Body)
|
||||
var fg fingerprint
|
||||
|
||||
if err := decoder.Decode(&fg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if fg.JA3 != JA3 {
|
||||
t.Fatalf("expected %q, got %q\n", JA3, fg.JA3)
|
||||
}
|
||||
}
|
||||
*/
|
19
utils/url.go
Normal file
19
utils/url.go
Normal file
@ -0,0 +1,19 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func TrimURL(u string) string {
|
||||
uu, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(uu.Path, "/") {
|
||||
return uu.Path
|
||||
}
|
||||
|
||||
return "/" + uu.Path
|
||||
}
|
40
views/album.templ
Normal file
40
views/album.templ
Normal file
@ -0,0 +1,40 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
templ AlbumPage(a data.Album) {
|
||||
@layout(fmt.Sprintf("%s - %s", a.Artist.Name, a.Name)) {
|
||||
<div id="container" class="trio-split">
|
||||
<div id="metadata">
|
||||
<img id="album-artwork" src={ data.ExtractImageURL(a.Image) } alt="Album image"/>
|
||||
<div id="metadata-info">
|
||||
<a href={ templ.URL(a.Artist.URL) } id="album-artist">
|
||||
<h2>{ a.Artist.Name }</h2>
|
||||
</a>
|
||||
<h1>{ a.Name }</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div id="album-tracklist">
|
||||
for _, t := range a.Tracks {
|
||||
<a href={ templ.URL(t.Url) } id="album-single-track">
|
||||
<small>{ fmt.Sprintf("%d", t.Number) }</small>
|
||||
<p>{ t.Title }</p>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
if a.About != "" {
|
||||
<div id="info">
|
||||
<div id="description" dir="auto">
|
||||
<h1 id="title">About</h1>
|
||||
<p class="hidden" id="full">{ a.About }</p>
|
||||
<p id="summary">{ utils.TrimText(a.About, 250) }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Artist}} - {{.Name}}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" type="text/css" href="/static/style.css" />
|
||||
<script type="text/javascript" src="/static/script.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar"}}
|
||||
<div id="container">
|
||||
<div id="metadata">
|
||||
<img id="album-artwork" src="{{extractURL .Image}}"/>
|
||||
<h2>{{.Artist}}</h2>
|
||||
<h1>{{.Name}}</h1>
|
||||
</div>
|
||||
<div id="album-tracklist">
|
||||
{{range .Tracks}}
|
||||
<a href="{{.Url}}">
|
||||
<p>{{.Title}}</p>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div id="info">
|
||||
<div id="about">
|
||||
<h1 id="title">About</h1>
|
||||
<p class="hidden" id="full_about">{{index .About 0}}</p>
|
||||
<p id="summary">{{index .About 1}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer"}}
|
||||
</body>
|
||||
</html>
|
34
views/article.templ
Normal file
34
views/article.templ
Normal file
@ -0,0 +1,34 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
)
|
||||
|
||||
templ ArticlePage(a data.Article) {
|
||||
@layout(a.Title) {
|
||||
<div id="container" class="solo">
|
||||
<div id="article-metadata">
|
||||
<h1 id="article-title">{ a.Title }</h1>
|
||||
<time datetime={ a.PublishedAt.Format(time.RFC3339) } id="article-date">
|
||||
{ a.PublishedAt.Format("2 Jan, 2006") }
|
||||
</time>
|
||||
</div>
|
||||
<img id="article-image" src={ a.Image } alt="Article image"/>
|
||||
<h2 id="article-subtitle">{ a.Subtitle }</h2>
|
||||
<div id="article-body">
|
||||
@templ.Raw(a.HTML)
|
||||
</div>
|
||||
<div id="article-authors">
|
||||
<h3>Authors</h3>
|
||||
for _, author := range a.Authors {
|
||||
<details>
|
||||
<summary>{ author.Name } - { author.Role }</summary>
|
||||
{ author.About }
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
49
views/artist.templ
Normal file
49
views/artist.templ
Normal file
@ -0,0 +1,49 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
templ ArtistPage(a data.Artist) {
|
||||
@layout(a.Name) {
|
||||
<div id="container" class="duo-split">
|
||||
<div id="metadata">
|
||||
<img id="artist-image" src={ data.ExtractImageURL(a.Image) } alt="Artist image"/>
|
||||
<div id="metadata-info">
|
||||
<h1 id="artist-name">{ a.Name }</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div id="artist-sections">
|
||||
if a.Description != "" {
|
||||
<div id="artist-section">
|
||||
<h2>About { a.Name }</h2>
|
||||
<div id="description">
|
||||
<p id="summary">
|
||||
{ utils.TrimText(a.Description, 500) }
|
||||
</p>
|
||||
<p id="full" class="hidden">{ a.Description }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
if len(a.Albums) > 0 {
|
||||
<div id="artist-section">
|
||||
<h2>Albums</h2>
|
||||
<div id="artist-albumlist">
|
||||
for _, album := range a.Albums {
|
||||
<a href={ templ.URL(album.URL) } id="artist-single-album">
|
||||
<img
|
||||
id="artwork-preview"
|
||||
src={ data.ExtractImageURL(album.Image) }
|
||||
alt="Album image"
|
||||
/>
|
||||
<p>{ album.Name }</p>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
12
views/error.templ
Normal file
12
views/error.templ
Normal file
@ -0,0 +1,12 @@
|
||||
package views
|
||||
|
||||
import "strconv"
|
||||
|
||||
templ ErrorPage(code int, display string) {
|
||||
@layout("Error - dumb") {
|
||||
<div id="error">
|
||||
<h1>{ strconv.Itoa(code) }</h1>
|
||||
<p>{ display }</p>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>dumb</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" type="text/css" href="/static/style.css" />
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
{{template "navbar"}}
|
||||
<div id="error">
|
||||
<h1>{{.Status}}</h1>
|
||||
<p>{{.Error}}</p>
|
||||
</div>
|
||||
{{template "footer"}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
15
views/footer.templ
Normal file
15
views/footer.templ
Normal file
@ -0,0 +1,15 @@
|
||||
package views
|
||||
|
||||
import "github.com/rramiachraf/dumb/data"
|
||||
|
||||
templ footer() {
|
||||
<footer>
|
||||
<div id="footer-container">
|
||||
<div id="footer-links">
|
||||
<a rel="noopener noreferrer" target="_blank" href="https://github.com/rramiachraf/dumb">Source Code</a>
|
||||
<a rel="noopener noreferrer" target="_blank" href="/instances.json">Instances</a>
|
||||
</div>
|
||||
<p id="version">v.{ data.Version }</p>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
{{define "footer"}}
|
||||
<footer>
|
||||
<a rel="noopener noreferrer" target="_blank" href="https://github.com/rramiachraf/dumb">Source Code</a>
|
||||
</footer>
|
||||
{{end}}
|
13
views/head.templ
Normal file
13
views/head.templ
Normal file
@ -0,0 +1,13 @@
|
||||
package views
|
||||
|
||||
templ head(title string) {
|
||||
<head>
|
||||
<title>{ title }</title>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="stylesheet" type="text/css" href="/static/style.css"/>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<script type="text/javascript" src="/static/script.js" defer></script>
|
||||
<meta name="description" content="An alternative frontend for genius.com"/>
|
||||
</head>
|
||||
}
|
15
views/home.templ
Normal file
15
views/home.templ
Normal file
@ -0,0 +1,15 @@
|
||||
package views
|
||||
|
||||
templ HomePage() {
|
||||
@layout("dumb") {
|
||||
<div id="home">
|
||||
<div>
|
||||
<h1>Welcome to dumb</h1>
|
||||
<p>An alternative frontend for genius.com</p>
|
||||
</div>
|
||||
<form method="GET" action="/search">
|
||||
<input type="text" name="q" id="search-input" placeholder="Search..."/>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>dumb</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" type="text/css" href="/static/style.css" />
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
{{template "navbar"}}
|
||||
<div id="home">
|
||||
<div>
|
||||
<h1>Welcome to dumb</h1>
|
||||
<p>An alternative frontend for genius.com</p>
|
||||
</div>
|
||||
<form method="GET" action="/search">
|
||||
<input type="text" name="q" id="search-input" placeholder="Search..." />
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{{template "footer"}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
15
views/layout.templ
Normal file
15
views/layout.templ
Normal file
@ -0,0 +1,15 @@
|
||||
package views
|
||||
|
||||
templ layout(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@head(title)
|
||||
<body>
|
||||
<main id="app">
|
||||
@navbar()
|
||||
{ children... }
|
||||
@footer()
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
59
views/lyrics.templ
Normal file
59
views/lyrics.templ
Normal file
@ -0,0 +1,59 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
templ LyricsPage(s data.Song) {
|
||||
@layout(fmt.Sprintf("%s - %s lyrics", s.Artist, s.Title)) {
|
||||
<div id="container" class="trio-split">
|
||||
<div id="metadata">
|
||||
<img id="album-artwork" src={ data.ExtractImageURL(s.Image) } alt="Song image"/>
|
||||
<div id="metadata-info">
|
||||
<a href={ templ.URL(s.ArtistPageURL) }>
|
||||
<h2>{ s.Artist }</h2>
|
||||
</a>
|
||||
<h1>{ s.Title }</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lyrics">
|
||||
@templ.Raw(s.Lyrics)
|
||||
</div>
|
||||
<div id="info">
|
||||
if s.About != "?" {
|
||||
<div id="description" dir="auto">
|
||||
<h1 id="title">About</h1>
|
||||
<p class="hidden" id="full">{ s.About }</p>
|
||||
<p id="summary">{ utils.TrimText(s.About, 250) }</p>
|
||||
</div>
|
||||
}
|
||||
if len(s.Credits) > 0 {
|
||||
<div id="credits">
|
||||
<h1 id="title">Credits</h1>
|
||||
for key, val := range s.Credits {
|
||||
<details>
|
||||
<summary>{ key }</summary>
|
||||
<p>{ val }</p>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if s.Album.Name != "" {
|
||||
<div id="lyrics-album-container">
|
||||
<h1 id="title">{ s.Album.Name }</h1>
|
||||
<a href={ templ.URL(s.Album.URL) }>
|
||||
<img
|
||||
title={ "Album: " + s.Album.Name }
|
||||
id="album-artwork"
|
||||
src={ s.Album.Image }
|
||||
alt="Album image"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Artist}} - {{.Title}} lyrics</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" type="text/css" href="/static/style.css" />
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<script type="text/javascript" src="/static/script.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar"}}
|
||||
<div id="container">
|
||||
<div id="metadata">
|
||||
<a href="{{.LinkToAlbum}}"><img id="album-artwork" src="{{extractURL .Image}}"/></a>
|
||||
<h2>{{.Artist}}</h2>
|
||||
<h1>{{.Title}}</h1>
|
||||
<a href="{{.LinkToAlbum}}"><h2>{{.Album}}</h2></a>
|
||||
</div>
|
||||
<div id="lyrics">{{.Lyrics}}</div>
|
||||
<div id="info">
|
||||
<div id="about">
|
||||
<h1 id="title">About</h1>
|
||||
<p class="hidden" id="full_about">{{index .About 0}}</p>
|
||||
<p id="summary">{{index .About 1}}</p>
|
||||
</div>
|
||||
<div id="credits">
|
||||
<h1 id="title">Credits</h1>
|
||||
{{range $key, $val := .Credits}}
|
||||
<details>
|
||||
<summary>{{$key}}</summary>
|
||||
<p>{{$val}}</p>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer"}}
|
||||
</body>
|
||||
</html>
|
41
views/navbar.templ
Normal file
41
views/navbar.templ
Normal file
@ -0,0 +1,41 @@
|
||||
package views
|
||||
|
||||
templ navbar() {
|
||||
<nav>
|
||||
<div id="nav-container">
|
||||
<a href="/"><img src="/static/logo.svg" alt="Logo"/></a>
|
||||
<div id="nav-icons">
|
||||
<a
|
||||
title="Go to Genius.com"
|
||||
alt="Go to Genius.com"
|
||||
class="nav-icon"
|
||||
id="goto-genius"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="25px" height="25px" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="#181d31" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<button id="choose-theme" class="nav-icon" type="button" aria-label="Change theme">
|
||||
<svg
|
||||
width="25px"
|
||||
height="25px"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<g id="🔍-Product-Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="ic_fluent_dark_theme_24_regular" fill="#181d31" fill-rule="nonzero">
|
||||
<path
|
||||
d="M12,22 C17.5228475,22 22,17.5228475 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,17.5228475 6.4771525,22 12,22 Z M12,20.5 L12,3.5 C16.6944204,3.5 20.5,7.30557963 20.5,12 C20.5,16.6944204 16.6944204,20.5 12,20.5 Z"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
{{define "navbar"}}
|
||||
<nav>
|
||||
<a href="/"><img src="/static/logo.svg" /></a>
|
||||
</nav>
|
||||
{{end}}
|
60
views/search.templ
Normal file
60
views/search.templ
Normal file
@ -0,0 +1,60 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"github.com/rramiachraf/dumb/data"
|
||||
"github.com/rramiachraf/dumb/utils"
|
||||
)
|
||||
|
||||
templ SearchPage(r data.SearchResults) {
|
||||
@layout("Search - dumb") {
|
||||
<div id="search-page" class="main">
|
||||
<form method="GET">
|
||||
<input type="text" name="q" id="search-input" placeholder="Search..." value={ r.Query }/>
|
||||
</form>
|
||||
<div id="search-results">
|
||||
for _, s := range r.Sections {
|
||||
if s.Type == "song" {
|
||||
<div id="search-section">
|
||||
<h2>Songs</h2>
|
||||
for _, s := range s.Hits {
|
||||
<a id="search-item" href={ templ.URL(s.Result.Path) }>
|
||||
<img src={ data.ExtractImageURL(s.Result.Thumbnail) } alt="Song image"/>
|
||||
<div>
|
||||
<span>{ s.Result.ArtistNames }</span>
|
||||
<h3>{ utils.TrimText(s.Result.Title,70) }</h3>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if s.Type == "album" {
|
||||
<div id="search-section">
|
||||
<h2>Albums</h2>
|
||||
for _, a := range s.Hits {
|
||||
<a id="search-item" href={ templ.URL(utils.TrimURL(a.Result.URL)) }>
|
||||
<img src={ data.ExtractImageURL(a.Result.AlbumImage) } alt="Album image"/>
|
||||
<div>
|
||||
<h3>{ utils.TrimText(a.Result.AlbumName, 70) }</h3>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if s.Type == "artist" {
|
||||
<div id="search-section">
|
||||
<h2>Artists</h2>
|
||||
for _, a := range s.Hits {
|
||||
<a id="search-item" href={ templ.URL(utils.TrimURL(a.Result.URL)) }>
|
||||
<img src={ data.ExtractImageURL(a.Result.ArtistImage) } alt="Artist image"/>
|
||||
<div>
|
||||
<h3>{ utils.TrimText(a.Result.ArtistName, 70) }</h3>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Search - dumb</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" type="text/css" href="/static/style.css" />
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
{{template "navbar"}}
|
||||
<div id="search-page" class="main">
|
||||
<form method="GET">
|
||||
<input type="text" name="q" id="search-input" placeholder="Search..." value="{{.Query}}" />
|
||||
</form>
|
||||
<div id="search-results">
|
||||
{{range .Sections}}
|
||||
{{if eq .Type "song"}}
|
||||
<h1>Songs</h1>
|
||||
{{range .Hits}}
|
||||
<a id="search-item" href="{{.Result.Path}}">
|
||||
<img src="{{extractURL .Result.Thumbnail}}"/>
|
||||
<div>
|
||||
<span>{{.Result.ArtistNames}}</span>
|
||||
<h2>{{.Result.Title}}</h2>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer"}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user