Compare commits
	
		
			6 Commits
		
	
	
		
			98584bbef6
			...
			88d9c4f2f8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 88d9c4f2f8 | |||
| 781d96ca14 | |||
| 8cff0ec6ab | |||
| 832e2025a0 | |||
| 474ea9b57c | |||
| 84803f1e3d | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -3,3 +3,4 @@ | ||||
| *.properties | ||||
| .DS_Store | ||||
| *.csv | ||||
| /vendor | ||||
| @@ -7,6 +7,7 @@ import ( | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.yetaga.in/alazyreader/library/config" | ||||
| 	"git.yetaga.in/alazyreader/library/database" | ||||
| @@ -22,24 +23,44 @@ func max(a, b int) int { | ||||
| 	return b | ||||
| } | ||||
|  | ||||
| func obscureStr(in string, l int) string { | ||||
| 	return in[0:max(l, len(in))] + strings.Repeat("*", max(0, len(in)-l)) | ||||
| } | ||||
|  | ||||
| type Library interface { | ||||
| 	GetAllBooks(context.Context) ([]media.Book, error) | ||||
| } | ||||
|  | ||||
| type RecordCollection interface { | ||||
| 	GetAllRecords(context.Context) ([]media.Record, error) | ||||
| } | ||||
|  | ||||
| type Router struct { | ||||
| 	static fs.FS | ||||
| 	lib    Library | ||||
| 	rcol   RecordCollection | ||||
| } | ||||
|  | ||||
| func writeJSON(w http.ResponseWriter, b []byte, status int) { | ||||
| 	w.Header().Set("Content-Type", "application/json; charset=utf-8") | ||||
| 	w.WriteHeader(status) | ||||
| 	w.Write(b) | ||||
| 	w.Write([]byte("\n")) | ||||
| } | ||||
|  | ||||
| func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { | ||||
| 	if req.URL.Path == "/api" { | ||||
| 		APIHandler(r.lib).ServeHTTP(w, req) | ||||
| 	if req.URL.Path == "/api/records" { | ||||
| 		RecordsAPIHandler(r.rcol).ServeHTTP(w, req) | ||||
| 		return | ||||
| 	} | ||||
| 	if req.URL.Path == "/api/books" { | ||||
| 		BooksAPIHandler(r.lib).ServeHTTP(w, req) | ||||
| 		return | ||||
| 	} | ||||
| 	StaticHandler(r.static).ServeHTTP(w, req) | ||||
| } | ||||
|  | ||||
| func APIHandler(l Library) http.Handler { | ||||
| func BooksAPIHandler(l Library) http.Handler { | ||||
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
| 		books, err := l.GetAllBooks(r.Context()) | ||||
| 		if err != nil { | ||||
| @@ -50,11 +71,25 @@ func APIHandler(l Library) http.Handler { | ||||
| 		if err != nil { | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
|  | ||||
| 		} | ||||
| 		w.Header().Set("Content-Type", "application/json") | ||||
| 		w.WriteHeader(http.StatusOK) | ||||
| 		w.Write(b) | ||||
| 		w.Write([]byte("\n")) | ||||
| 		writeJSON(w, b, http.StatusOK) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func RecordsAPIHandler(l RecordCollection) http.Handler { | ||||
| 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||
| 		books, err := l.GetAllRecords(r.Context()) | ||||
| 		if err != nil { | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 		b, err := json.Marshal(books) | ||||
| 		if err != nil { | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 		writeJSON(w, b, http.StatusOK) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| @@ -73,8 +108,11 @@ func main() { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" { | ||||
| 		if c.DBPass != "" { // obscure password | ||||
| 			c.DBPass = c.DBPass[0:max(3, len(c.DBPass))] + strings.Repeat("*", max(0, len(c.DBPass)-3)) | ||||
| 		if c.DBPass != "" { | ||||
| 			c.DBPass = obscureStr(c.DBPass, 3) | ||||
| 		} | ||||
| 		if c.DiscogsToken != "" { | ||||
| 			c.DiscogsToken = obscureStr(c.DiscogsToken, 3) | ||||
| 		} | ||||
| 		log.Fatalf("vars: %+v", c) | ||||
| 	} | ||||
| @@ -91,10 +129,16 @@ func main() { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	log.Printf("latest migration: %d; migrations run: %d", latest, run) | ||||
| 	discogsCache, err := database.NewDiscogsCache(c.DiscogsToken, time.Hour*24, "delta.mu.alpha") | ||||
| 	if err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	go discogsCache.FlushCache(context.Background()) | ||||
| 	r := &Router{ | ||||
| 		static: f, | ||||
| 		lib:    lib, | ||||
| 		rcol:   discogsCache, | ||||
| 	} | ||||
| 	log.Println("Listening on http://localhost:8080/") | ||||
| 	log.Println("Listening on http://0.0.0.0:8080/") | ||||
| 	log.Fatalln(http.ListenAndServe(":8080", r)) | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| package config | ||||
|  | ||||
| type Config struct { | ||||
| 	DBUser string | ||||
| 	DBPass string | ||||
| 	DBHost string | ||||
| 	DBPort string | ||||
| 	DBName string | ||||
| 	Debug  bool | ||||
| 	DBUser       string | ||||
| 	DBPass       string | ||||
| 	DBHost       string | ||||
| 	DBPort       string | ||||
| 	DBName       string | ||||
| 	DiscogsToken string | ||||
| 	Debug        bool | ||||
| } | ||||
|   | ||||
| @@ -49,15 +49,15 @@ func (m *MySQL) PrepareDatabase(ctx context.Context) error { | ||||
| 		return fmt.Errorf("uninitialized mysql client") | ||||
| 	} | ||||
|  | ||||
| 	tablecheck := `SELECT count(*) AS count | ||||
| 	tablecheck := fmt.Sprintf(`SELECT count(*) AS count | ||||
| 		FROM information_schema.TABLES | ||||
| 		WHERE TABLE_NAME = '` + m.versionTable + `' | ||||
| 		AND TABLE_SCHEMA in (SELECT DATABASE());` | ||||
| 	tableschema := `CREATE TABLE ` + m.versionTable + `( | ||||
| 		WHERE TABLE_NAME = '%s' | ||||
| 		AND TABLE_SCHEMA in (SELECT DATABASE());`, m.versionTable) | ||||
| 	tableschema := fmt.Sprintf(`CREATE TABLE %s ( | ||||
| 		id INT NOT NULL, | ||||
| 		name VARCHAR(100) NOT NULL, | ||||
| 		datetime DATE, | ||||
| 		PRIMARY KEY (id))` | ||||
| 		PRIMARY KEY (id))`, m.versionTable) | ||||
|  | ||||
| 	var versionTableExists int | ||||
| 	m.connection.QueryRowContext(ctx, tablecheck).Scan(&versionTableExists) | ||||
| @@ -73,8 +73,9 @@ func (m *MySQL) GetLatestMigration(ctx context.Context) (int, error) { | ||||
| 		return 0, fmt.Errorf("uninitialized mysql client") | ||||
| 	} | ||||
|  | ||||
| 	migrationCheck := fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", m.versionTable) | ||||
| 	var latestMigration int | ||||
| 	err := m.connection.QueryRowContext(ctx, "SELECT COALESCE(MAX(id), 0) FROM "+m.versionTable).Scan(&latestMigration) | ||||
| 	err := m.connection.QueryRowContext(ctx, migrationCheck).Scan(&latestMigration) | ||||
| 	return latestMigration, err | ||||
| } | ||||
|  | ||||
| @@ -97,6 +98,9 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) { | ||||
| 			} | ||||
| 			mig.id, mig.name = id, name | ||||
| 			mig.content, err = fs.ReadFile(migrationsFS, m.migrationsDirectory+"/"+dir[f].Name()) | ||||
| 			if err != nil { | ||||
| 				return 0, 0, fmt.Errorf("failure loading migration: %w", err) | ||||
| 			} | ||||
| 			migrations[mig.id] = mig | ||||
| 		} | ||||
| 	} | ||||
| @@ -116,6 +120,7 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) { | ||||
| 	if err != nil { | ||||
| 		return latestMigrationRan, 0, err | ||||
| 	} | ||||
| 	migrationLogSql := fmt.Sprintf("INSERT INTO %s (id, name, datetime) VALUES (?, ?, ?)", m.versionTable) | ||||
| 	migrationsRun := 0 | ||||
| 	for migrationsToRun := true; migrationsToRun; _, migrationsToRun = migrations[latestMigrationRan+1] { | ||||
| 		mig := migrations[latestMigrationRan+1] | ||||
| @@ -127,7 +132,7 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) { | ||||
| 			} | ||||
| 			return latestMigrationRan, migrationsRun, err | ||||
| 		} | ||||
| 		_, err = tx.ExecContext(ctx, "INSERT INTO "+m.versionTable+" (id, name, datetime) VALUES (?, ?, ?)", mig.id, mig.name, time.Now()) | ||||
| 		_, err = tx.ExecContext(ctx, migrationLogSql, mig.id, mig.name, time.Now()) | ||||
| 		if err != nil { | ||||
| 			nestederr := tx.Rollback() | ||||
| 			if nestederr != nil { | ||||
| @@ -147,26 +152,14 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) { | ||||
| 		return nil, fmt.Errorf("uninitialized mysql client") | ||||
| 	} | ||||
|  | ||||
| 	allBooksQuery := fmt.Sprintf(`SELECT | ||||
| 		id, title, authors, sortauthor, isbn10, isbn13, format, | ||||
| 		genre, publisher, series, volume, year, signed, | ||||
| 		description, notes, onloan, coverurl | ||||
| 	FROM %s`, m.tableName) | ||||
|  | ||||
| 	books := []media.Book{} | ||||
| 	rows, err := m.connection.QueryContext(ctx, ` | ||||
| 		SELECT id, | ||||
| 			title, | ||||
| 			authors, | ||||
| 			sortauthor, | ||||
| 			isbn10, | ||||
| 			isbn13, | ||||
| 			format, | ||||
| 			genre, | ||||
| 			publisher, | ||||
| 			series, | ||||
| 			volume, | ||||
| 			year, | ||||
| 			signed, | ||||
| 			description, | ||||
| 			notes, | ||||
| 			onloan, | ||||
| 			coverurl | ||||
| 		FROM `+m.tableName) | ||||
| 	rows, err := m.connection.QueryContext(ctx, allBooksQuery) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										112
									
								
								database/records.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								database/records.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | ||||
| package database | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"strconv" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.yetaga.in/alazyreader/library/media" | ||||
| 	"github.com/irlndts/go-discogs" | ||||
| ) | ||||
|  | ||||
| type DiscogsCache struct { | ||||
| 	authToken   string | ||||
| 	m           sync.Mutex | ||||
| 	cache       []media.Record | ||||
| 	maxCacheAge time.Duration | ||||
| 	lastRefresh time.Time | ||||
| 	client      discogs.Discogs | ||||
| 	username    string | ||||
| } | ||||
|  | ||||
| func NewDiscogsCache(token string, maxCacheAge time.Duration, username string) (*DiscogsCache, error) { | ||||
| 	client, err := discogs.New(&discogs.Options{ | ||||
| 		UserAgent: "library.yetaga.in personal collection cache", | ||||
| 		Token:     token, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &DiscogsCache{ | ||||
| 		authToken:   token, | ||||
| 		client:      client, | ||||
| 		maxCacheAge: maxCacheAge, | ||||
| 		username:    username, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (c *DiscogsCache) FlushCache(ctx context.Context) error { | ||||
| 	c.m.Lock() | ||||
| 	defer c.m.Unlock() | ||||
| 	records, err := c.fetchRecords(ctx, nil) | ||||
| 	c.cache = records | ||||
| 	c.lastRefresh = time.Now() | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (c *DiscogsCache) GetAllRecords(ctx context.Context) ([]media.Record, error) { | ||||
| 	c.m.Lock() | ||||
| 	defer c.m.Unlock() | ||||
| 	if time.Now().After(c.lastRefresh.Add(c.maxCacheAge)) { | ||||
| 		records, err := c.fetchRecords(ctx, nil) | ||||
| 		c.cache = records | ||||
| 		c.lastRefresh = time.Now() | ||||
| 		return c.cache, err | ||||
| 	} | ||||
| 	return c.cache, nil | ||||
| } | ||||
|  | ||||
| func (c *DiscogsCache) fetchRecords(ctx context.Context, pagination *discogs.Pagination) ([]media.Record, error) { | ||||
| 	records := []media.Record{} | ||||
| 	if pagination == nil { | ||||
| 		pagination = getPagination(1) | ||||
| 	} | ||||
| 	log.Printf("calling discogs API, page %v", pagination.Page) | ||||
| 	coll, err := c.client.CollectionItemsByFolder(c.username, 0, pagination) | ||||
| 	if err != nil { | ||||
| 		return records, fmt.Errorf("error loading collection: %w", err) | ||||
| 	} | ||||
| 	log.Printf("length: %v, first item in list: %s", len(coll.Items), coll.Items[0].BasicInformation.Title) | ||||
| 	for i := range coll.Items { | ||||
| 		records = append(records, collectionItemToRecord(&coll.Items[i])) | ||||
| 	} | ||||
| 	// recurse down the list | ||||
| 	if coll.Pagination.URLs.Next != "" { | ||||
| 		coll, err := c.fetchRecords(ctx, getPagination(pagination.Page+1)) | ||||
| 		if err != nil { | ||||
| 			return records, err | ||||
| 		} | ||||
| 		records = append(records, coll...) | ||||
| 	} | ||||
| 	return records, nil | ||||
| } | ||||
|  | ||||
| func getPagination(page int) *discogs.Pagination { | ||||
| 	return &discogs.Pagination{Page: page, Sort: "added", SortOrder: "asc", PerPage: 100} | ||||
| } | ||||
|  | ||||
| func collectionItemToRecord(item *discogs.CollectionItemSource) media.Record { | ||||
| 	artists := []string{} | ||||
| 	for _, artist := range item.BasicInformation.Artists { | ||||
| 		artists = append(artists, artist.Name) | ||||
| 	} | ||||
| 	year := strconv.Itoa(item.BasicInformation.Year) | ||||
| 	if year == "0" { | ||||
| 		year = "" | ||||
| 	} | ||||
| 	return media.Record{ | ||||
| 		ID:         item.ID, | ||||
| 		AlbumName:  item.BasicInformation.Title, | ||||
| 		Artists:    artists, | ||||
| 		Identifier: item.BasicInformation.Labels[0].Catno, | ||||
| 		Format:     item.BasicInformation.Formats[0].Name, | ||||
| 		Genre:      item.BasicInformation.Genres[0], | ||||
| 		Label:      item.BasicInformation.Labels[0].Name, | ||||
| 		Year:       year, | ||||
| 		CoverURL:   item.BasicInformation.CoverImage, | ||||
| 		DiscogsURL: fmt.Sprintf("https://www.discogs.com/release/%v", item.ID), | ||||
| 	} | ||||
| } | ||||
| @@ -4,7 +4,7 @@ var sortState = { | ||||
| }; | ||||
|  | ||||
| function init() { | ||||
|   fetch("/api") | ||||
|   fetch("/api/books") | ||||
|     .then((response) => response.json()) | ||||
|     .then((books) => { | ||||
|       // prepare response | ||||
|   | ||||
| @@ -22,6 +22,7 @@ | ||||
|     <div class="wrapper"> | ||||
|       <div id="header"> | ||||
|         <h1>Library</h1> | ||||
|         <a href="/records">records</a> | ||||
|         <a | ||||
|           target="_blank" | ||||
|           rel="noreferrer" | ||||
|   | ||||
							
								
								
									
										176
									
								
								frontend/files/records/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								frontend/files/records/app.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| var sortState = { | ||||
|   sortBy: "sortArtist", | ||||
|   sortOrder: "asc", | ||||
| }; | ||||
|  | ||||
| function init() { | ||||
|   fetch("/api/records") | ||||
|     .then((response) => response.json()) | ||||
|     .then((records) => { | ||||
|       // prepare response | ||||
|       records.forEach(apiResponseParsing); | ||||
|       document.getElementById("search").addEventListener("input", (e) => { | ||||
|         renderTable(search(records, e.target.value)); | ||||
|       }); | ||||
|       renderTable(records); | ||||
|     }); | ||||
| } | ||||
|  | ||||
| function renderTable(records, sortField) { | ||||
|   if (sortField) { | ||||
|     if (sortState.sortBy === sortField && sortState.sortOrder === "asc") { | ||||
|       sortState.sortOrder = "desc"; | ||||
|     } else { | ||||
|       sortState.sortOrder = "asc"; | ||||
|     } | ||||
|     sortState.sortBy = sortField; | ||||
|   } | ||||
|   records.sort((one, two) => | ||||
|     (one[sortState.sortBy] + one["sortName"]).localeCompare( | ||||
|       two[sortState.sortBy] + two["sortName"] | ||||
|     ) | ||||
|   ); | ||||
|   if (sortState.sortOrder === "desc") { | ||||
|     records.reverse(); | ||||
|   } | ||||
|   records.forEach((e, i) => (e.rowNumber = i)); // re-key | ||||
|  | ||||
|   // rendering | ||||
|   var recordElement = document.getElementById("records"); | ||||
|   recordElement.innerHTML = TableTemplate(records); | ||||
|  | ||||
|   // add listeners for selecting record to view | ||||
|   Array.from(recordElement.querySelectorAll("tbody tr")) | ||||
|     .slice(1) // remove header from Array | ||||
|     .forEach((row) => { | ||||
|       row.addEventListener("click", (e) => { | ||||
|         // add listener to swap current record | ||||
|         document.getElementById("current").innerHTML = RecordTemplate( | ||||
|           records[e.currentTarget.id] | ||||
|         ); | ||||
|       }); | ||||
|     }); | ||||
|   // add sorting callbacks | ||||
|   Array.from( | ||||
|     recordElement.querySelectorAll("tbody tr th[data-sort-by]") | ||||
|   ).forEach((row) => { | ||||
|     row.addEventListener("click", function (e) { | ||||
|       renderTable(records, e.target.dataset.sortBy); // only add callback when there's a sortBy attribute | ||||
|     }); | ||||
|   }); | ||||
|   // mark currently active column | ||||
|   recordElement | ||||
|     .querySelector("tbody tr th[data-sort-by=" + sortState.sortBy + "]") | ||||
|     .classList.add(sortState.sortOrder); | ||||
| } | ||||
|  | ||||
| function apiResponseParsing(record) { | ||||
|   record.sortName = titleCleaner(record.name); | ||||
|   record.artists = record.artists.map((artist) => { | ||||
|     return artist.replace(/ \([0-9]+\)$/, ""); | ||||
|   }); | ||||
|   record.sortArtist = record.artists.reduce((acc, curr) => { | ||||
|     return ( | ||||
|       acc + | ||||
|       curr | ||||
|         .replace(/^(An?|The)\s/i, "") | ||||
|         .toLowerCase() | ||||
|         .replaceAll('"', "") | ||||
|         .replaceAll(":", "") | ||||
|         .replaceAll("'", "") | ||||
|         .replaceAll(" ", "") | ||||
|     ); | ||||
|   }, ""); | ||||
|   return record; | ||||
| } | ||||
|  | ||||
| function search(records, searchBy) { | ||||
|   searchBy = searchCleaner(searchBy); | ||||
|   if (searchBy !== "") { | ||||
|     records = records.filter(({ name, artists, genre, label, year }) => { | ||||
|       return Object.values({ | ||||
|         name, | ||||
|         artists: artists.join(" "), | ||||
|         genre, | ||||
|         label, | ||||
|         year, | ||||
|       }).find((field) => searchCleaner(field).indexOf(searchBy) !== -1); | ||||
|     }); | ||||
|   } | ||||
|   return records; | ||||
| } | ||||
|  | ||||
| function titleCleaner(title) { | ||||
|   return title | ||||
|     .replace('"', "") | ||||
|     .replace(":", "") | ||||
|     .replace(/^(An?|The)\s/i, ""); | ||||
| } | ||||
|  | ||||
| function searchCleaner(str) { | ||||
|   return str | ||||
|     .toLowerCase() | ||||
|     .replaceAll('"', "") | ||||
|     .replaceAll(":", "") | ||||
|     .replaceAll("'", "") | ||||
|     .replaceAll(" ", ""); | ||||
| } | ||||
|  | ||||
| function RecordTemplate({ | ||||
|   name, | ||||
|   artists, | ||||
|   coverURL, | ||||
|   format, | ||||
|   genre, | ||||
|   identifier, | ||||
|   label, | ||||
|   year, | ||||
|   discogsURL, | ||||
| }) { | ||||
|   return `${coverURL ? `<img src="${coverURL}"/>` : ""} | ||||
|   <h1>${name}</h1> | ||||
|   <h2>${artists.join(", ")}</h2> | ||||
|   <span>${identifier}</span><br/> | ||||
|   <span>${genre}, ${label}, ${year}</span><br/> | ||||
|   <span>${format}</span> | ||||
|   <div> | ||||
|     <a  | ||||
|       target="_blank" | ||||
|       href="${discogsURL}" | ||||
|     > | ||||
|       Data provided by Discogs. | ||||
|     </a> | ||||
|   </div>`; | ||||
| } | ||||
|  | ||||
| function TableRowTemplate({ | ||||
|   artists, | ||||
|   identifier, | ||||
|   label, | ||||
|   rowNumber, | ||||
|   name, | ||||
|   year, | ||||
| }) { | ||||
|   return `<tr class="tRow" id="${rowNumber}"> | ||||
|     <td class="name"> | ||||
|       ${name} | ||||
|     </td> | ||||
|     <td class="artist">${artists.join(", ")}</td> | ||||
|     <td class="label">${label}</td> | ||||
|     <td class="identifier">${identifier}</td> | ||||
|     <td class="year">${year}</td> | ||||
|   </tr>`; | ||||
| } | ||||
|  | ||||
| function TableTemplate(records) { | ||||
|   return `<table class="recordTable"> | ||||
|     <tr> | ||||
|       <th data-sort-by="sortName" class="tHeader name">Name</th> | ||||
|       <th data-sort-by="sortArtist" class="tHeader artist">Artist(s)</th> | ||||
|       <th data-sort-by="label" class="tHeader label">Label</th> | ||||
|       <th data-sort-by="identifier" class="tHeader identifier">Identifier</th> | ||||
|       <th data-sort-by="year" class="tHeader year">Year</th> | ||||
|     </tr>${records.reduce((acc, record) => { | ||||
|       return acc.concat(TableRowTemplate(record)); | ||||
|     }, "")} </table>`; | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								frontend/files/records/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/files/records/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 174 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/files/records/favicon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/files/records/favicon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 37 KiB | 
							
								
								
									
										46
									
								
								frontend/files/records/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/files/records/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|   <head> | ||||
|     <title>Library</title> | ||||
|     <link | ||||
|       rel="stylesheet" | ||||
|       href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" | ||||
|     /> | ||||
|     <link rel="stylesheet" href="style.css" /> | ||||
|     <link rel="icon" href="favicon.ico" type="image/x-icon" /> | ||||
|     <link | ||||
|       href="https://fonts.googleapis.com/css?family=Libre+Baskerville:400,700&display=swap" | ||||
|       as="style" | ||||
|       rel="stylesheet preload prefetch" | ||||
|     /> | ||||
|     <script type="text/javascript" src="app.js"></script> | ||||
|     <script type="text/javascript"> | ||||
|       window.addEventListener("DOMContentLoaded", init); | ||||
|     </script> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div class="wrapper"> | ||||
|       <div id="header"> | ||||
|         <h1>Records</h1> | ||||
|         <a href="/">books</a> | ||||
|         <a | ||||
|           target="_blank" | ||||
|           rel="noreferrer" | ||||
|           href="https://git.yetaga.in/alazyreader/library" | ||||
|           >git</a | ||||
|         > | ||||
|         <div id="searchBox"> | ||||
|           <input | ||||
|             id="search" | ||||
|             type="text" | ||||
|             name="search" | ||||
|             placeholder="Search..." | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div id="current">No Record Selected</div> | ||||
|       <div id="records"></div> | ||||
|       <!-- Table goes here --> | ||||
|     </div> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										284
									
								
								frontend/files/records/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										284
									
								
								frontend/files/records/style.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,284 @@ | ||||
| /* http://meyerweb.com/eric/tools/css/reset/  | ||||
|    v2.0 | 20110126 | ||||
|    License: none (public domain) | ||||
| */ | ||||
|  | ||||
| html, | ||||
| body, | ||||
| div, | ||||
| span, | ||||
| applet, | ||||
| object, | ||||
| iframe, | ||||
| h1, | ||||
| h2, | ||||
| h3, | ||||
| h4, | ||||
| h5, | ||||
| h6, | ||||
| p, | ||||
| blockquote, | ||||
| pre, | ||||
| a, | ||||
| abbr, | ||||
| acronym, | ||||
| address, | ||||
| big, | ||||
| cite, | ||||
| code, | ||||
| del, | ||||
| dfn, | ||||
| em, | ||||
| img, | ||||
| ins, | ||||
| kbd, | ||||
| q, | ||||
| s, | ||||
| samp, | ||||
| small, | ||||
| strike, | ||||
| strong, | ||||
| sub, | ||||
| sup, | ||||
| tt, | ||||
| var, | ||||
| b, | ||||
| u, | ||||
| i, | ||||
| center, | ||||
| dl, | ||||
| dt, | ||||
| dd, | ||||
| ol, | ||||
| ul, | ||||
| li, | ||||
| fieldset, | ||||
| form, | ||||
| label, | ||||
| legend, | ||||
| table, | ||||
| caption, | ||||
| tbody, | ||||
| tfoot, | ||||
| thead, | ||||
| tr, | ||||
| th, | ||||
| td, | ||||
| article, | ||||
| aside, | ||||
| canvas, | ||||
| details, | ||||
| embed, | ||||
| figure, | ||||
| figcaption, | ||||
| footer, | ||||
| header, | ||||
| hgroup, | ||||
| menu, | ||||
| nav, | ||||
| output, | ||||
| ruby, | ||||
| section, | ||||
| summary, | ||||
| time, | ||||
| mark, | ||||
| audio, | ||||
| video { | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   border: 0; | ||||
|   font-size: 100%; | ||||
|   font: inherit; | ||||
|   vertical-align: baseline; | ||||
| } | ||||
| article, | ||||
| aside, | ||||
| details, | ||||
| figcaption, | ||||
| figure, | ||||
| footer, | ||||
| header, | ||||
| hgroup, | ||||
| menu, | ||||
| nav, | ||||
| section { | ||||
|   display: block; | ||||
| } | ||||
| body { | ||||
|   line-height: 1; | ||||
| } | ||||
| ol, | ||||
| ul { | ||||
|   list-style: none; | ||||
| } | ||||
| blockquote, | ||||
| q { | ||||
|   quotes: none; | ||||
| } | ||||
| blockquote:before, | ||||
| blockquote:after, | ||||
| q:before, | ||||
| q:after { | ||||
|   content: ""; | ||||
|   content: none; | ||||
| } | ||||
| table { | ||||
|   border-collapse: collapse; | ||||
|   border-spacing: 0; | ||||
| } | ||||
|  | ||||
| /* site CSS starts here */ | ||||
|  | ||||
| body { | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| #header { | ||||
|   height: 30px; | ||||
|   width: calc(100vw - 20px); | ||||
|   padding: 4px 10px; | ||||
|   background-color: #f7f3dc; | ||||
|   border-bottom: 2px solid #d8d0a0; | ||||
|   font-family: "Libre Baskerville", sans-serif; | ||||
| } | ||||
|  | ||||
| #header h1 { | ||||
|   font-size: xx-large; | ||||
|   display: inline; | ||||
| } | ||||
|  | ||||
| #searchBox { | ||||
|   position: absolute; | ||||
|   right: 10px; | ||||
|   top: 7px; | ||||
|   text-align: right; | ||||
|   width: 400px; | ||||
| } | ||||
|  | ||||
| #searchBox input { | ||||
|   width: 300px; | ||||
|   font-size: 16px; | ||||
|   background: #f9f8ed; | ||||
|   padding: 2px 5px; | ||||
|   border: none; | ||||
|   border-bottom: 2px solid #d8d0a0; | ||||
|   font-family: "Libre Baskerville", sans-serif; | ||||
| } | ||||
|  | ||||
| #searchBox input:focus { | ||||
|   outline: none; | ||||
| } | ||||
|  | ||||
| #searchBox input::placeholder { | ||||
|   font-family: "Libre Baskerville", sans-serif; | ||||
|   color: #d8d0a0; | ||||
| } | ||||
|  | ||||
| #current { | ||||
|   background-color: #f7f3dc; | ||||
|   width: calc(40vw - 40px); | ||||
|   height: calc(100vh - 80px); | ||||
|   padding: 20px; | ||||
|   overflow: auto; | ||||
|   float: left; | ||||
| } | ||||
|  | ||||
| #records { | ||||
|   width: calc(60vw - 40px); | ||||
|   height: calc(100vh - 80px); | ||||
|   padding: 20px; | ||||
|   overflow: auto; | ||||
|   float: left; | ||||
| } | ||||
|  | ||||
| .recordTable th { | ||||
|   font-weight: bold; | ||||
|   text-align: left; | ||||
|   font-family: "Libre Baskerville", sans-serif; | ||||
| } | ||||
|  | ||||
| .recordTable th[data-sort-by] { | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .recordTable th[data-sort-by]::after { | ||||
|   content: "\f0dc"; | ||||
|   font-family: FontAwesome; | ||||
|   font-size: x-small; | ||||
|   position: relative; | ||||
|   left: 4px; | ||||
|   bottom: 2px; | ||||
| } | ||||
|  | ||||
| .recordTable th.asc::after { | ||||
|   content: "\f0de"; | ||||
|   font-family: FontAwesome; | ||||
|   font-size: x-small; | ||||
|   position: relative; | ||||
|   left: 4px; | ||||
|   bottom: 2px; | ||||
| } | ||||
|  | ||||
| .recordTable th.desc::after { | ||||
|   content: "\f0dd"; | ||||
|   font-family: FontAwesome; | ||||
|   font-size: x-small; | ||||
|   position: relative; | ||||
|   left: 4px; | ||||
|   bottom: 2px; | ||||
| } | ||||
|  | ||||
| .recordTable td, | ||||
| .recordTable th { | ||||
|   padding: 5px; | ||||
|   min-width: 50px; | ||||
| } | ||||
|  | ||||
| .tRow:nth-child(odd) { | ||||
|   background: #f9f8ed; | ||||
|   border-bottom: 1px solid #d8d0a0; | ||||
| } | ||||
|  | ||||
| .recordTable .tRow { | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .recordTable .onLoan { | ||||
|   color: #bbb; | ||||
| } | ||||
|  | ||||
| .recordTable .tRow .title { | ||||
|   font-style: italic; | ||||
|   max-width: 600px; | ||||
| } | ||||
|  | ||||
| #current h1 { | ||||
|   font-size: x-large; | ||||
|   font-weight: bold; | ||||
|   font-style: italic; | ||||
|   padding: 10px 0; | ||||
| } | ||||
|  | ||||
| #current h2 { | ||||
|   font-size: large; | ||||
|   padding: 7px 0; | ||||
| } | ||||
|  | ||||
| #current img { | ||||
|   max-height: 400px; | ||||
|   max-width: 100%; | ||||
|   display: block; | ||||
|   margin: 0 auto; | ||||
| } | ||||
|  | ||||
| #current .description p { | ||||
|   padding: 20px 0; | ||||
| } | ||||
|  | ||||
| #current h1.onLoan { | ||||
|   color: #bbb; | ||||
| } | ||||
|  | ||||
| #current h2.onLoan { | ||||
|   font-weight: bold; | ||||
| } | ||||
							
								
								
									
										3
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								go.mod
									
									
									
									
									
								
							| @@ -2,8 +2,11 @@ module git.yetaga.in/alazyreader/library | ||||
|  | ||||
| go 1.16 | ||||
|  | ||||
| replace github.com/irlndts/go-discogs v0.3.5 => git.yetaga.in/alazyreader/go-discogs v0.3.6 | ||||
|  | ||||
| require ( | ||||
| 	github.com/gdamore/tcell v1.4.0 | ||||
| 	github.com/go-sql-driver/mysql v1.6.0 | ||||
| 	github.com/irlndts/go-discogs v0.3.5 | ||||
| 	github.com/kelseyhightower/envconfig v1.4.0 | ||||
| ) | ||||
|   | ||||
							
								
								
									
										7
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,9 +1,15 @@ | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.5 h1:XcwcFJP0p1eQQ6OQhScYiNM8+vymeEO4V+M9H9x58os= | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.5/go.mod h1:UVQ05FdCzH4P/usnSxQDh77QYE37HvmPnSCgogioljo= | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.6 h1:VhV8/uhnWsxae6PvIVtXOfO4eWWqShX6DkiN2hFsZ8U= | ||||
| git.yetaga.in/alazyreader/go-discogs v0.3.6/go.mod h1:UVQ05FdCzH4P/usnSxQDh77QYE37HvmPnSCgogioljo= | ||||
| github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= | ||||
| github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= | ||||
| github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= | ||||
| github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= | ||||
| github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= | ||||
| github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= | ||||
| github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= | ||||
| github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= | ||||
| github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= | ||||
| github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= | ||||
| @@ -14,3 +20,4 @@ golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeo | ||||
| golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= | ||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
|   | ||||
| @@ -21,4 +21,16 @@ type Book struct { | ||||
| } | ||||
|  | ||||
| type Record struct { | ||||
| 	ID          int      `json:"-"` | ||||
| 	AlbumName   string   `json:"name"` | ||||
| 	Artists     []string `json:"artists"` | ||||
| 	SortArtist  string   `json:"sortArtist"` | ||||
| 	Identifier  string   `json:"identifier"` | ||||
| 	Format      string   `json:"format"` | ||||
| 	Genre       string   `json:"genre"` | ||||
| 	Label       string   `json:"label"` | ||||
| 	Year        string   `json:"year"` | ||||
| 	Description string   `json:"description"` | ||||
| 	CoverURL    string   `json:"coverURL"` | ||||
| 	DiscogsURL  string   `json:"discogsURL"` | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user