[add] progress streaming
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				continuous-integration/drone/push Build is passing
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	continuous-integration/drone/push Build is passing
				
			This commit is contained in:
		
							parent
							
								
									2c240f2f5c
								
							
						
					
					
						commit
						3057b86002
					
				
							
								
								
									
										13
									
								
								api/api.go
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								api/api.go
									
									
									
									
									
								
							| @ -26,15 +26,16 @@ type API struct { | |||||||
| 	DB         *database.DBManager | 	DB         *database.DBManager | ||||||
| 	HTMLPolicy *bluemonday.Policy | 	HTMLPolicy *bluemonday.Policy | ||||||
| 	Assets     *embed.FS | 	Assets     *embed.FS | ||||||
|  | 	Templates  map[string]*template.Template | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func NewApi(db *database.DBManager, c *config.Config, assets embed.FS) *API { | func NewApi(db *database.DBManager, c *config.Config, assets *embed.FS) *API { | ||||||
| 	api := &API{ | 	api := &API{ | ||||||
| 		HTMLPolicy: bluemonday.StrictPolicy(), | 		HTMLPolicy: bluemonday.StrictPolicy(), | ||||||
| 		Router:     gin.Default(), | 		Router:     gin.Default(), | ||||||
| 		Config:     c, | 		Config:     c, | ||||||
| 		DB:         db, | 		DB:         db, | ||||||
| 		Assets:     &assets, | 		Assets:     assets, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Assets & Web App Templates | 	// Assets & Web App Templates | ||||||
| @ -168,6 +169,7 @@ func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) { | |||||||
| 
 | 
 | ||||||
| func (api *API) generateTemplates() *multitemplate.Renderer { | func (api *API) generateTemplates() *multitemplate.Renderer { | ||||||
| 	// Define Templates & Helper Functions | 	// Define Templates & Helper Functions | ||||||
|  | 	templates := make(map[string]*template.Template) | ||||||
| 	render := multitemplate.NewRenderer() | 	render := multitemplate.NewRenderer() | ||||||
| 	helperFuncs := template.FuncMap{ | 	helperFuncs := template.FuncMap{ | ||||||
| 		"GetSVGGraphData": getSVGGraphData, | 		"GetSVGGraphData": getSVGGraphData, | ||||||
| @ -189,6 +191,7 @@ func (api *API) generateTemplates() *multitemplate.Renderer { | |||||||
| 
 | 
 | ||||||
| 		b, _ := api.Assets.ReadFile(path) | 		b, _ := api.Assets.ReadFile(path) | ||||||
| 		baseTemplate = template.Must(baseTemplate.New("svg/" + name).Parse(string(b))) | 		baseTemplate = template.Must(baseTemplate.New("svg/" + name).Parse(string(b))) | ||||||
|  | 		templates["svg/"+name] = baseTemplate | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Load Components | 	// Load Components | ||||||
| @ -198,8 +201,11 @@ func (api *API) generateTemplates() *multitemplate.Renderer { | |||||||
| 		path := fmt.Sprintf("templates/components/%s", basename) | 		path := fmt.Sprintf("templates/components/%s", basename) | ||||||
| 		name := strings.TrimSuffix(basename, filepath.Ext(basename)) | 		name := strings.TrimSuffix(basename, filepath.Ext(basename)) | ||||||
| 
 | 
 | ||||||
|  | 		// Clone Base Template | ||||||
| 		b, _ := api.Assets.ReadFile(path) | 		b, _ := api.Assets.ReadFile(path) | ||||||
| 		baseTemplate = template.Must(baseTemplate.New("component/" + name).Parse(string(b))) | 		baseTemplate = template.Must(baseTemplate.New("component/" + name).Parse(string(b))) | ||||||
|  | 		render.Add("component/"+name, baseTemplate) | ||||||
|  | 		templates["component/"+name] = baseTemplate | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Load Pages | 	// Load Pages | ||||||
| @ -213,8 +219,11 @@ func (api *API) generateTemplates() *multitemplate.Renderer { | |||||||
| 		b, _ := api.Assets.ReadFile(path) | 		b, _ := api.Assets.ReadFile(path) | ||||||
| 		pageTemplate, _ := template.Must(baseTemplate.Clone()).New("page/" + name).Parse(string(b)) | 		pageTemplate, _ := template.Must(baseTemplate.Clone()).New("page/" + name).Parse(string(b)) | ||||||
| 		render.Add("page/"+name, pageTemplate) | 		render.Add("page/"+name, pageTemplate) | ||||||
|  | 		templates["page/"+name] = pageTemplate | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	api.Templates = templates | ||||||
|  | 
 | ||||||
| 	return &render | 	return &render | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -773,22 +773,63 @@ func (api *API) saveNewDocument(c *gin.Context) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Render Initial Template | ||||||
|  | 	var userID string | ||||||
|  | 	if rUser, _ := c.Get("AuthorizedUser"); rUser != nil { | ||||||
|  | 		userID = rUser.(string) | ||||||
|  | 	} | ||||||
|  | 	templateVars := gin.H{ | ||||||
|  | 		"RouteName":     "search", | ||||||
|  | 		"SearchEnabled": api.Config.SearchEnabled, | ||||||
|  | 		"User":          userID, | ||||||
|  | 	} | ||||||
|  | 	c.HTML(http.StatusOK, "page/search", templateVars) | ||||||
|  | 
 | ||||||
|  | 	// Create Streamer | ||||||
|  | 	stream := api.newStreamer(c) | ||||||
|  | 	defer stream.close() | ||||||
|  | 
 | ||||||
|  | 	// Stream Helper Function | ||||||
|  | 	sendDownloadMessage := func(msg string, args ...map[string]any) { | ||||||
|  | 		// Merge Defaults & Overrides | ||||||
|  | 		var templateVars = gin.H{ | ||||||
|  | 			"Message":    msg, | ||||||
|  | 			"ButtonText": "Close", | ||||||
|  | 			"ButtonHref": "./search", | ||||||
|  | 		} | ||||||
|  | 		if len(args) > 0 { | ||||||
|  | 			for key := range args[0] { | ||||||
|  | 				templateVars[key] = args[0][key] | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		stream.send("component/download-progress", templateVars) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Send Message | ||||||
|  | 	sendDownloadMessage("Downloading document...", gin.H{"Progress": 10}) | ||||||
|  | 
 | ||||||
| 	// Save Book | 	// Save Book | ||||||
| 	tempFilePath, err := search.SaveBook(rDocAdd.ID, rDocAdd.Source) | 	tempFilePath, err := search.SaveBook(rDocAdd.ID, rDocAdd.Source) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Warn("[saveNewDocument] Temp File Error: ", err) | 		log.Warn("[saveNewDocument] Temp File Error: ", err) | ||||||
| 		errorPage(c, http.StatusInternalServerError, "Unable to save file.") | 		sendDownloadMessage("Unable to download file", gin.H{"Error": true}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Send Message | ||||||
|  | 	sendDownloadMessage("Calculating partial MD5...", gin.H{"Progress": 60}) | ||||||
|  | 
 | ||||||
| 	// Calculate Partial MD5 ID | 	// Calculate Partial MD5 ID | ||||||
| 	partialMD5, err := utils.CalculatePartialMD5(tempFilePath) | 	partialMD5, err := utils.CalculatePartialMD5(tempFilePath) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Warn("[saveNewDocument] Partial MD5 Error: ", err) | 		log.Warn("[saveNewDocument] Partial MD5 Error: ", err) | ||||||
| 		errorPage(c, http.StatusInternalServerError, "Unable to calculate partial MD5.") | 		sendDownloadMessage("Unable to calculate partial MD5", gin.H{"Error": true}) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Send Message | ||||||
|  | 	sendDownloadMessage("Saving file...", gin.H{"Progress": 60}) | ||||||
|  | 
 | ||||||
| 	// Derive Extension on MIME | 	// Derive Extension on MIME | ||||||
| 	fileMime, err := mimetype.DetectFile(tempFilePath) | 	fileMime, err := mimetype.DetectFile(tempFilePath) | ||||||
| 	fileExtension := fileMime.Extension() | 	fileExtension := fileMime.Extension() | ||||||
| @ -817,7 +858,7 @@ func (api *API) saveNewDocument(c *gin.Context) { | |||||||
| 	sourceFile, err := os.Open(tempFilePath) | 	sourceFile, err := os.Open(tempFilePath) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error("[saveNewDocument] Source File Error:", err) | 		log.Error("[saveNewDocument] Source File Error:", err) | ||||||
| 		errorPage(c, http.StatusInternalServerError, "Unable to save file.") | 		sendDownloadMessage("Unable to open file", gin.H{"Error": true}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	defer os.Remove(tempFilePath) | 	defer os.Remove(tempFilePath) | ||||||
| @ -828,7 +869,7 @@ func (api *API) saveNewDocument(c *gin.Context) { | |||||||
| 	destFile, err := os.Create(safePath) | 	destFile, err := os.Create(safePath) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error("[saveNewDocument] Dest File Error:", err) | 		log.Error("[saveNewDocument] Dest File Error:", err) | ||||||
| 		errorPage(c, http.StatusInternalServerError, "Unable to save file.") | 		sendDownloadMessage("Unable to create file", gin.H{"Error": true}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	defer destFile.Close() | 	defer destFile.Close() | ||||||
| @ -836,26 +877,35 @@ func (api *API) saveNewDocument(c *gin.Context) { | |||||||
| 	// Copy File | 	// Copy File | ||||||
| 	if _, err = io.Copy(destFile, sourceFile); err != nil { | 	if _, err = io.Copy(destFile, sourceFile); err != nil { | ||||||
| 		log.Error("[saveNewDocument] Copy Temp File Error:", err) | 		log.Error("[saveNewDocument] Copy Temp File Error:", err) | ||||||
| 		errorPage(c, http.StatusInternalServerError, "Unable to save file.") | 		sendDownloadMessage("Unable to save file", gin.H{"Error": true}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Send Message | ||||||
|  | 	sendDownloadMessage("Calculating MD5...", gin.H{"Progress": 70}) | ||||||
|  | 
 | ||||||
| 	// Get MD5 Hash | 	// Get MD5 Hash | ||||||
| 	fileHash, err := getFileMD5(safePath) | 	fileHash, err := getFileMD5(safePath) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error("[saveNewDocument] Hash Failure:", err) | 		log.Error("[saveNewDocument] Hash Failure:", err) | ||||||
| 		errorPage(c, http.StatusInternalServerError, "Unable to calculate MD5.") | 		sendDownloadMessage("Unable to calculate MD5", gin.H{"Error": true}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Send Message | ||||||
|  | 	sendDownloadMessage("Calculating word count...", gin.H{"Progress": 80}) | ||||||
|  | 
 | ||||||
| 	// Get Word Count | 	// Get Word Count | ||||||
| 	wordCount, err := metadata.GetWordCount(safePath) | 	wordCount, err := metadata.GetWordCount(safePath) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Error("[saveNewDocument] Word Count Failure:", err) | 		log.Error("[saveNewDocument] Word Count Failure:", err) | ||||||
| 		errorPage(c, http.StatusInternalServerError, "Unable to calculate word count.") | 		sendDownloadMessage("Unable to calculate word count", gin.H{"Error": true}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Send Message | ||||||
|  | 	sendDownloadMessage("Saving to database...", gin.H{"Progress": 90}) | ||||||
|  | 
 | ||||||
| 	// Upsert Document | 	// Upsert Document | ||||||
| 	if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ | 	if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ | ||||||
| 		ID:       partialMD5, | 		ID:       partialMD5, | ||||||
| @ -866,11 +916,16 @@ func (api *API) saveNewDocument(c *gin.Context) { | |||||||
| 		Words:    &wordCount, | 		Words:    &wordCount, | ||||||
| 	}); err != nil { | 	}); err != nil { | ||||||
| 		log.Error("[saveNewDocument] UpsertDocument DB Error:", err) | 		log.Error("[saveNewDocument] UpsertDocument DB Error:", err) | ||||||
| 		errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) | 		sendDownloadMessage("Unable to save to database", gin.H{"Error": true}) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	c.Redirect(http.StatusFound, fmt.Sprintf("./documents/%s", partialMD5)) | 	// Send Message | ||||||
|  | 	sendDownloadMessage("Download Success", gin.H{ | ||||||
|  | 		"Progress":   100, | ||||||
|  | 		"ButtonText": "Go to Book", | ||||||
|  | 		"ButtonHref": fmt.Sprintf("./documents/%s", partialMD5), | ||||||
|  | 	}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (api *API) editSettings(c *gin.Context) { | func (api *API) editSettings(c *gin.Context) { | ||||||
|  | |||||||
							
								
								
									
										79
									
								
								api/streamer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								api/streamer.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,79 @@ | |||||||
|  | package api | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"bytes" | ||||||
|  | 	"html/template" | ||||||
|  | 	"net/http" | ||||||
|  | 	"sync" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/gin-gonic/gin" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type streamer struct { | ||||||
|  | 	templates  map[string]*template.Template | ||||||
|  | 	writer     gin.ResponseWriter | ||||||
|  | 	mutex      sync.Mutex | ||||||
|  | 	completeCh chan struct{} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (api *API) newStreamer(c *gin.Context) *streamer { | ||||||
|  | 	stream := &streamer{ | ||||||
|  | 		templates:  api.Templates, | ||||||
|  | 		writer:     c.Writer, | ||||||
|  | 		completeCh: make(chan struct{}), | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Set Headers | ||||||
|  | 	header := stream.writer.Header() | ||||||
|  | 	header.Set("Transfer-Encoding", "chunked") | ||||||
|  | 	header.Set("Content-Type", "text/html; charset=utf-8") | ||||||
|  | 	header.Set("X-Content-Type-Options", "nosniff") | ||||||
|  | 	stream.writer.WriteHeader(http.StatusOK) | ||||||
|  | 
 | ||||||
|  | 	// Send Open Element Tags | ||||||
|  | 	stream.write(` | ||||||
|  | 	  <div class="absolute top-0 left-0 w-full h-full z-50"> | ||||||
|  | 	    <div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div> | ||||||
|  | 	    <div id="stream-main" class="relative max-h-[95%] -translate-x-2/4 top-1/2 left-1/2 w-5/6">`) | ||||||
|  | 
 | ||||||
|  | 	// Keep Alive | ||||||
|  | 	go func() { | ||||||
|  | 		closeCh := stream.writer.CloseNotify() | ||||||
|  | 		for { | ||||||
|  | 			select { | ||||||
|  | 			case <-stream.completeCh: | ||||||
|  | 				return | ||||||
|  | 			case <-closeCh: | ||||||
|  | 				return | ||||||
|  | 			default: | ||||||
|  | 				stream.write("<!-- ping -->") | ||||||
|  | 				time.Sleep(2 * time.Second) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  | 
 | ||||||
|  | 	return stream | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (stream *streamer) write(str string) { | ||||||
|  | 	stream.mutex.Lock() | ||||||
|  | 	stream.writer.WriteString(str) | ||||||
|  | 	stream.writer.(http.Flusher).Flush() | ||||||
|  | 	stream.mutex.Unlock() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (stream *streamer) send(templateName string, templateVars gin.H) { | ||||||
|  | 	t := stream.templates[templateName] | ||||||
|  | 	buf := &bytes.Buffer{} | ||||||
|  | 	_ = t.ExecuteTemplate(buf, templateName, templateVars) | ||||||
|  | 	stream.write(buf.String()) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (stream *streamer) close() { | ||||||
|  | 	// Send Close Element Tags | ||||||
|  | 	stream.write(`</div></div>`) | ||||||
|  | 
 | ||||||
|  | 	// Close | ||||||
|  | 	close(stream.completeCh) | ||||||
|  | } | ||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										13
									
								
								assets/sw.js
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								assets/sw.js
									
									
									
									
									
								
							| @ -253,6 +253,13 @@ self.addEventListener("install", function (event) { | |||||||
|   event.waitUntil(handleInstall(event)); |   event.waitUntil(handleInstall(event)); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| self.addEventListener("fetch", (event) => | self.addEventListener("fetch", (event) => { | ||||||
|   event.respondWith(handleFetch(event)) |   /** | ||||||
| ); |    * Weird things happen when a service worker attempts to handle a request | ||||||
|  |    * when the server responds with chunked transfer encoding. Right now we only | ||||||
|  |    * use chunked encoding on POSTs. So this is to avoid processing those. | ||||||
|  |    **/ | ||||||
|  | 
 | ||||||
|  |   if (event.request.method != "GET") return; | ||||||
|  |   return event.respondWith(handleFetch(event)); | ||||||
|  | }); | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								main.go
									
									
									
									
									
								
							| @ -55,7 +55,7 @@ func cmdServer(ctx *cli.Context) error { | |||||||
| 	signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) | 	signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) | ||||||
| 
 | 
 | ||||||
| 	// Start Server | 	// Start Server | ||||||
| 	server := server.NewServer(assets) | 	server := server.NewServer(&assets) | ||||||
| 	server.StartServer(&wg, done) | 	server.StartServer(&wg, done) | ||||||
| 
 | 
 | ||||||
| 	// Wait & Close | 	// Wait & Close | ||||||
|  | |||||||
| @ -23,7 +23,7 @@ type Server struct { | |||||||
| 	httpServer *http.Server | 	httpServer *http.Server | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func NewServer(assets embed.FS) *Server { | func NewServer(assets *embed.FS) *Server { | ||||||
| 	c := config.Load() | 	c := config.Load() | ||||||
| 	db := database.NewMgr(c) | 	db := database.NewMgr(c) | ||||||
| 	api := api.NewApi(db, c, assets) | 	api := api.NewApi(db, c, assets) | ||||||
|  | |||||||
| @ -1,9 +1,9 @@ | |||||||
| /** @type {import('tailwindcss').Config} */ | /** @type {import('tailwindcss').Config} */ | ||||||
| module.exports = { | module.exports = { | ||||||
|   content: [ |   content: [ | ||||||
|     "./templates/**/*.html", |     "./templates/**/*.{html,htm,svg}", | ||||||
|     "./assets/local/*.{html,js}", |     "./assets/local/*.{html,htm,svg,js}", | ||||||
|     "./assets/reader/*.{html,js}", |     "./assets/reader/*.{html,htm,svg,js}", | ||||||
|   ], |   ], | ||||||
|   theme: { |   theme: { | ||||||
|     extend: {}, |     extend: {}, | ||||||
|  | |||||||
							
								
								
									
										40
									
								
								templates/components/download-progress.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								templates/components/download-progress.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,40 @@ | |||||||
|  | <div | ||||||
|  |   class="absolute -translate-y-1/2 p-4 m-auto bg-gray-700 dark:bg-gray-300 rounded-lg shadow w-full text-black dark:text-white" | ||||||
|  | > | ||||||
|  |   <span | ||||||
|  |     class="inline-flex gap-2 items-center font-medium text-xs inline-block py-1 px-2 uppercase rounded-full {{ if .Error }} bg-red-500 {{ else }} bg-green-600 {{ end }}" | ||||||
|  |   > | ||||||
|  |     {{ if and (ne .Progress 100) (not .Error) }} | ||||||
|  |       {{ template "svg/loading" (dict "Size" 16) }}  | ||||||
|  |     {{ end }} | ||||||
|  | 
 | ||||||
|  |     {{ .Message }} | ||||||
|  |   </span> | ||||||
|  |   <div class="flex flex-col gap-2 mt-2"> | ||||||
|  |     <div class="relative w-full h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> | ||||||
|  | 
 | ||||||
|  |       {{ if .Error }} | ||||||
|  | 	<div | ||||||
|  | 	  class="absolute h-full bg-red-500 rounded-full" | ||||||
|  | 	  style="width: 100%" | ||||||
|  | 	></div> | ||||||
|  | 	<p class="absolute w-full h-full font-bold text-center text-xs">ERROR</p> | ||||||
|  |       {{ else }} | ||||||
|  | 	<div | ||||||
|  | 	  class="absolute h-full bg-green-600 rounded-full" | ||||||
|  | 	  style="width: {{ .Progress }}%" | ||||||
|  | 	></div> | ||||||
|  | 	<p class="absolute w-full h-full font-bold text-center text-xs"> | ||||||
|  | 	  {{ .Progress }}% | ||||||
|  | 	</p> | ||||||
|  |       {{ end }} | ||||||
|  | 
 | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <a | ||||||
|  |       href="{{ .ButtonHref }}" | ||||||
|  |       class="w-full text-center font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100" | ||||||
|  |       >{{ .ButtonText }}</a | ||||||
|  |     > | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @ -40,7 +40,7 @@ | |||||||
| <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> | <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> | ||||||
|   {{range $doc := .Data }} |   {{range $doc := .Data }} | ||||||
|   <div class="w-full relative"> |   <div class="w-full relative"> | ||||||
|     <div class="flex gap-4 w-full h-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"> |     <div class="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"> | ||||||
|       <div class="min-w-fit my-auto h-48 relative"> |       <div class="min-w-fit my-auto h-48 relative"> | ||||||
| 	<a href="./documents/{{$doc.ID}}"> | 	<a href="./documents/{{$doc.ID}}"> | ||||||
| 	  <img class="rounded object-cover h-full" src="./documents/{{$doc.ID}}/cover"></img> | 	  <img class="rounded object-cover h-full" src="./documents/{{$doc.ID}}/cover"></img> | ||||||
|  | |||||||
							
								
								
									
										45
									
								
								templates/svgs/loading.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								templates/svgs/loading.svg
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,45 @@ | |||||||
|  | <svg | ||||||
|  |   width="{{ or .Size 24 }}" | ||||||
|  |   height="{{ or .Size 24 }}" | ||||||
|  | 
 | ||||||
|  |   viewBox="0 0 24 24" | ||||||
|  |   fill="currentColor" | ||||||
|  |   xmlns="http://www.w3.org/2000/svg" | ||||||
|  | > | ||||||
|  |   <style> | ||||||
|  |     .spinner_l9ve { | ||||||
|  |       animation: spinner_rcyq 1.2s cubic-bezier(0.52, 0.6, 0.25, 0.99) infinite; | ||||||
|  |     } | ||||||
|  |     .spinner_cMYp { | ||||||
|  |       animation-delay: 0.4s; | ||||||
|  |     } | ||||||
|  |     .spinner_gHR3 { | ||||||
|  |       animation-delay: 0.8s; | ||||||
|  |     } | ||||||
|  |     @keyframes spinner_rcyq { | ||||||
|  |       0% { | ||||||
|  |         transform: translate(12px, 12px) scale(0); | ||||||
|  |         opacity: 1; | ||||||
|  |       } | ||||||
|  |       100% { | ||||||
|  |         transform: translate(0, 0) scale(1); | ||||||
|  |         opacity: 0; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   </style> | ||||||
|  |   <path | ||||||
|  |     class="spinner_l9ve" | ||||||
|  |     d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z" | ||||||
|  |     transform="translate(12, 12) scale(0)" | ||||||
|  |   /> | ||||||
|  |   <path | ||||||
|  |     class="spinner_l9ve spinner_cMYp" | ||||||
|  |     d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z" | ||||||
|  |     transform="translate(12, 12) scale(0)" | ||||||
|  |   /> | ||||||
|  |   <path | ||||||
|  |     class="spinner_l9ve spinner_gHR3" | ||||||
|  |     d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z" | ||||||
|  |     transform="translate(12, 12) scale(0)" | ||||||
|  |   /> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 1.1 KiB | 
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user