[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…
Reference in New Issue
Block a user