Continue updating UI

This commit is contained in:
Radhi Fadlillah 2018-05-19 13:36:51 +07:00
parent 0ffd6b3231
commit 9048fe4bdc
11 changed files with 225 additions and 116 deletions

File diff suppressed because one or more lines are too long

View file

@ -100,28 +100,48 @@ func (h *webHandler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, p
err = json.NewDecoder(r.Body).Decode(&book)
checkError(err)
// Make sure URL valid
parsedURL, err := nurl.ParseRequestURI(book.URL)
if err != nil || parsedURL.Host == "" {
panic(fmt.Errorf("URL is not valid"))
}
// Clear UTM parameter from URL
clearUTMParams(parsedURL)
book.URL = parsedURL.String()
// Get new bookmark id
book.ID, err = h.db.GetNewID("bookmark")
checkError(err)
// Fetch data from internet
article, err := readability.Parse(book.URL, 20*time.Second)
checkError(err)
article, _ := readability.Parse(parsedURL, 20*time.Second)
book.URL = article.URL
book.Title = article.Meta.Title
book.Excerpt = article.Meta.Excerpt
book.Author = article.Meta.Author
book.MinReadTime = article.Meta.MinReadTime
book.MaxReadTime = article.Meta.MaxReadTime
book.Content = article.Content
book.HTML = article.RawContent
// If title and excerpt doesnt have submitted value, use from article
if book.Title == "" {
book.Title = article.Meta.Title
}
if book.Excerpt == "" {
book.Excerpt = article.Meta.Excerpt
}
// Make sure title is not empty
if book.Title == "" {
book.Title = book.URL
}
// Check if book has content
if book.Content != "" {
book.HasContent = true
}
// Save bookmark image to local disk
imgPath := fp.Join(h.dataDir, "thumb", fmt.Sprintf("%d", book.ID))
err = downloadFile(article.Meta.Image, imgPath, 20*time.Second)
@ -138,6 +158,24 @@ func (h *webHandler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, p
checkError(err)
}
// apiDeleteBookmarks is handler for DELETE /api/bookmark
func (h *webHandler) apiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Check token
err := h.checkAPIToken(r)
checkError(err)
// Decode request
indices := []string{}
err = json.NewDecoder(r.Body).Decode(&indices)
checkError(err)
// Delete bookmarks
err = h.db.DeleteBookmarks(indices...)
checkError(err)
fmt.Fprint(w, 1)
}
// apiUpdateBookmark is handler for PUT /api/bookmark
func (h *webHandler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Check token
@ -157,6 +195,7 @@ func (h *webHandler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, p
if err != nil || parsedURL.Host == "" {
panic(fmt.Errorf("URL is not valid"))
}
clearUTMParams(parsedURL)
// Get existing bookmark from database
bookmarks, err := h.db.GetBookmarks(true, fmt.Sprintf("%d", request.ID))
@ -167,10 +206,10 @@ func (h *webHandler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, p
}
book := bookmarks[0]
book.URL = request.URL
book.URL = parsedURL.String()
// Fetch data from internet
article, err := readability.Parse(book.URL, 10*time.Second)
article, err := readability.Parse(parsedURL, 10*time.Second)
checkError(err)
book.ImageURL = article.Meta.Image
@ -243,24 +282,6 @@ func (h *webHandler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, p
checkError(err)
}
// apiDeleteBookmarks is handler for DELETE /api/bookmark
func (h *webHandler) apiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Check token
err := h.checkAPIToken(r)
checkError(err)
// Decode request
indices := []string{}
err = json.NewDecoder(r.Body).Decode(&indices)
checkError(err)
// Delete bookmarks
err = h.db.DeleteBookmarks(indices...)
checkError(err)
fmt.Fprint(w, 1)
}
func downloadFile(url, dstPath string, timeout time.Duration) error {
// Fetch data from URL
client := &http.Client{Timeout: timeout}
@ -291,3 +312,14 @@ func downloadFile(url, dstPath string, timeout time.Duration) error {
return nil
}
func clearUTMParams(url *nurl.URL) {
newQuery := nurl.Values{}
for key, value := range url.Query() {
if !strings.HasPrefix(key, "utm_") {
newQuery[key] = value
}
}
url.RawQuery = newQuery.Encode()
}

View file

@ -181,12 +181,27 @@ func (db *SQLiteDatabase) GetBookmarks(withContent bool, indices ...string) ([]m
return nil, err
}
// Create query
query := `SELECT
b.id, b.url, b.title, b.image_url, b.excerpt, b.author,
b.min_read_time, b.max_read_time, b.modified, bc.content <> "" has_content
FROM bookmark b
LEFT JOIN bookmark_content bc ON bc.docid = b.id`
if withContent {
query = `SELECT
b.id, b.url, b.title, b.image_url, b.excerpt, b.author,
b.min_read_time, b.max_read_time, b.modified, bc.content, bc.html
FROM bookmark b
LEFT JOIN bookmark_content bc ON bc.docid = b.id`
}
// Prepare where clause
args := []interface{}{}
whereClause := " WHERE 1"
if len(listIndex) > 0 {
whereClause = " WHERE id IN ("
whereClause = " WHERE b.id IN ("
for _, idx := range listIndex {
args = append(args, idx)
whereClause += "?,"
@ -197,32 +212,21 @@ func (db *SQLiteDatabase) GetBookmarks(withContent bool, indices ...string) ([]m
}
// Fetch bookmarks
query := `SELECT id,
url, title, image_url, excerpt, author,
min_read_time, max_read_time, modified
FROM bookmark` + whereClause
query += whereClause
bookmarks := []model.Bookmark{}
err = db.Select(&bookmarks, query, args...)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
// Fetch tags and contents for each bookmarks
// Fetch tags for each bookmarks
stmtGetTags, err := db.Preparex(`SELECT t.id, t.name
FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id
WHERE bt.bookmark_id = ? ORDER BY t.name`)
if err != nil {
return nil, err
}
stmtGetContent, err := db.Preparex(`SELECT title, content, html FROM bookmark_content WHERE docid = ?`)
if err != nil {
return nil, err
}
defer stmtGetTags.Close()
defer stmtGetContent.Close()
for i, book := range bookmarks {
book.Tags = []model.Tag{}
@ -231,13 +235,6 @@ func (db *SQLiteDatabase) GetBookmarks(withContent bool, indices ...string) ([]m
return nil, err
}
if withContent {
err = stmtGetContent.Get(&book, book.ID)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
}
bookmarks[i] = book
}
@ -300,22 +297,25 @@ func (db *SQLiteDatabase) DeleteBookmarks(indices ...string) (err error) {
// SearchBookmarks search bookmarks by the keyword or tags.
func (db *SQLiteDatabase) SearchBookmarks(orderLatest bool, keyword string, tags ...string) ([]model.Bookmark, error) {
// Create initial variable
keyword = strings.TrimSpace(keyword)
whereClause := "WHERE 1"
// Prepare query
args := []interface{}{}
query := `SELECT
b.id, b.url, b.title, b.image_url, b.excerpt, b.author,
b.min_read_time, b.max_read_time, b.modified, bc.content <> "" has_content
FROM bookmark b
LEFT JOIN bookmark_content bc ON bc.docid = b.id
WHERE 1`
// Create where clause for keyword
keyword = strings.TrimSpace(keyword)
if keyword != "" {
whereClause += ` AND (url LIKE ? OR id IN (
SELECT docid id FROM bookmark_content
WHERE title MATCH ? OR content MATCH ?))`
query += ` AND (b.url LIKE ? OR bc.title MATCH ? OR bc.content MATCH ?)`
args = append(args, "%"+keyword+"%", keyword, keyword)
}
// Create where clause for tags
if len(tags) > 0 {
whereTagClause := ` AND id IN (
whereTagClause := ` AND b.id IN (
SELECT bookmark_id FROM bookmark_tag
WHERE tag_id IN (SELECT id FROM tag WHERE name IN (`
@ -328,19 +328,15 @@ func (db *SQLiteDatabase) SearchBookmarks(orderLatest bool, keyword string, tags
whereTagClause += `)) GROUP BY bookmark_id HAVING COUNT(bookmark_id) >= ?)`
args = append(args, len(tags))
whereClause += whereTagClause
query += whereTagClause
}
// Search bookmarks
query := `SELECT id,
url, title, image_url, excerpt, author,
min_read_time, max_read_time, modified
FROM bookmark ` + whereClause
// Set order clause
if orderLatest {
query += ` ORDER BY id DESC`
}
// Fetch bookmarks
bookmarks := []model.Bookmark{}
err := db.Select(&bookmarks, query, args...)
if err != nil && err != sql.ErrNoRows {

View file

@ -21,6 +21,7 @@ type Bookmark struct {
Modified string `db:"modified" json:"modified"`
Content string `db:"content" json:"-"`
HTML string `db:"html" json:"-"`
HasContent bool `db:"has_content" json:"hasContent"`
Tags []Tag `json:"tags"`
}

View file

@ -1131,24 +1131,9 @@ func estimateReadTime(articleContent *goquery.Selection) (int, int) {
}
// Parse an URL to readability format
func Parse(url string, timeout time.Duration) (Article, error) {
// Make sure url is valid
parsedURL, err := nurl.ParseRequestURI(url)
if err != nil {
return Article{}, err
}
// Clear UTM parameters from URL
newQuery := nurl.Values{}
for key, value := range parsedURL.Query() {
if !strings.HasPrefix(key, "utm_") {
newQuery[key] = value
}
}
parsedURL.RawQuery = newQuery.Encode()
func Parse(url *nurl.URL, timeout time.Duration) (Article, error) {
// Fetch page
doc, err := fetchURL(parsedURL, timeout)
doc, err := fetchURL(url, timeout)
if err != nil {
return Article{}, err
}
@ -1165,7 +1150,7 @@ func Parse(url string, timeout time.Duration) (Article, error) {
}
// Post process content
postProcessContent(articleContent, parsedURL)
postProcessContent(articleContent, url)
// Estimate read time
minTime, maxTime := estimateReadTime(articleContent)
@ -1188,7 +1173,7 @@ func Parse(url string, timeout time.Duration) (Article, error) {
htmlContent := getHTMLContent(articleContent)
article := Article{
URL: parsedURL.String(),
URL: url.String(),
Meta: metadata,
Content: textContent,
RawContent: htmlContent,

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
.header-link{border-right:1px solid #E5E5E5;color:#232323;cursor:pointer;font-size:.9em;line-height:60px;overflow:hidden;padding:0 16px}.header-link:hover{color:#F44336}.full-overlay{position:fixed;z-index:101;display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column;background-color:rgba(0,0,0,0.5);top:0;left:0;right:0;bottom:0;overflow:hidden;-webkit-box-pack:center;justify-content:center;padding:32px}.yla-dialog__overlay{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;-webkit-box-align:center;align-items:center;-webkit-box-pack:center;justify-content:center;min-width:0;min-height:0;overflow:hidden;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10001;background-color:rgba(0,0,0,0.6);padding:32px}.yla-dialog__overlay .yla-dialog{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;min-width:400px;min-height:0;max-height:100%;overflow:hidden;background-color:#FFF;font-size:16px}.yla-dialog__overlay .yla-dialog>.yla-dialog__header{display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row nowrap;padding:16px;min-height:0;background-color:#353535;color:#EEE;flex-shrink:0}.yla-dialog__overlay .yla-dialog>.yla-dialog__header>p{-webkit-box-flex:1;flex:1 0;font-weight:600;font-size:1em;text-transform:uppercase}.yla-dialog__overlay .yla-dialog>.yla-dialog__header>a:hover{color:#F44336}.yla-dialog__overlay .yla-dialog>.yla-dialog__body{padding:16px;display:grid;max-height:100%;min-height:80px;min-width:0;font-size:.9em;overflow:auto;grid-template-columns:max-content 1fr;-webkit-box-align:baseline;align-items:baseline;grid-gap:16px}.yla-dialog__overlay .yla-dialog>.yla-dialog__body>.yla-dialog__content{grid-column-start:1;grid-column-end:3;align-self:baseline}.yla-dialog__overlay .yla-dialog>.yla-dialog__body>input{color:#232323;padding:8px;border:1px solid #E5E5E5}.yla-dialog__overlay .yla-dialog>.yla-dialog__body>.suggestion{position:absolute;display:block;padding:8px;background-color:#EEE;border:1px solid #E5E5E5}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer{padding:16px;display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row wrap;-webkit-box-pack:end;justify-content:flex-end;border-top:1px solid #E5E5E5}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer>a{text-transform:uppercase;padding:0 8px;font-size:.9em;font-weight:600}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer>a:hover{color:#F44336}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer>a:focus{outline:none;color:#F44336;border-bottom:1px dashed #F44336}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer>i{width:19px;line-height:19px;text-align:center}
.header-link{border-right:1px solid #E5E5E5;color:#232323;cursor:pointer;font-size:.9em;line-height:60px;overflow:hidden;padding:0 16px}.header-link:hover{color:#F44336}.full-overlay{position:fixed;z-index:101;display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column;background-color:rgba(0,0,0,0.5);top:0;left:0;right:0;bottom:0;overflow:hidden;-webkit-box-pack:center;justify-content:center;padding:32px}.yla-dialog__overlay{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;-webkit-box-align:center;align-items:center;-webkit-box-pack:center;justify-content:center;min-width:0;min-height:0;overflow:hidden;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10001;background-color:rgba(0,0,0,0.6);padding:32px}.yla-dialog__overlay .yla-dialog{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;min-width:400px;min-height:0;max-height:100%;overflow:hidden;background-color:#FFF;font-size:16px}.yla-dialog__overlay .yla-dialog>.yla-dialog__header{display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row nowrap;padding:16px;min-height:0;background-color:#353535;color:#EEE;flex-shrink:0}.yla-dialog__overlay .yla-dialog>.yla-dialog__header>p{-webkit-box-flex:1;flex:1 0;font-weight:600;font-size:1em;text-transform:uppercase}.yla-dialog__overlay .yla-dialog>.yla-dialog__header>a:hover{color:#F44336}.yla-dialog__overlay .yla-dialog>.yla-dialog__body{padding:16px;display:grid;max-height:100%;min-height:80px;min-width:0;overflow:auto;grid-template-columns:max-content 1fr;-webkit-box-align:baseline;align-items:baseline;grid-gap:16px}.yla-dialog__overlay .yla-dialog>.yla-dialog__body>.yla-dialog__content{grid-column-start:1;grid-column-end:3;align-self:baseline;font-size:.9em}.yla-dialog__overlay .yla-dialog>.yla-dialog__body>input,.yla-dialog__overlay .yla-dialog>.yla-dialog__body>textarea{color:#232323;padding:8px;border:1px solid #E5E5E5;font-size:.9em;min-height:37px}.yla-dialog__overlay .yla-dialog>.yla-dialog__body>.suggestion{position:absolute;display:block;padding:8px;background-color:#EEE;border:1px solid #E5E5E5}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer{padding:16px;display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row wrap;-webkit-box-pack:end;justify-content:flex-end;border-top:1px solid #E5E5E5}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer>a{text-transform:uppercase;padding:0 8px;font-size:.9em;font-weight:600;border-bottom:1px dashed transparent}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer>a:hover{color:#F44336}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer>a:focus{outline:none;color:#F44336;border-bottom:1px dashed #F44336}.yla-dialog__overlay .yla-dialog>.yla-dialog__footer>i{width:19px;line-height:19px;text-align:center}

View file

@ -62,8 +62,8 @@
</a>
</div>
<div id="grid">
<div class="bookmark" v-for="book in bookmarks">
<a class="bookmark-content" :href="'/bookmark/'+book.id" target="_blank">
<div class="bookmark" v-for="(book, idx) in bookmarks">
<a class="bookmark-content" :href="book.hasContent ? '/bookmark/'+book.id : null" target="_blank">
<img v-if="book.imageURL !== ''" :src="book.imageURL">
<p class="title">{{book.title}}</p>
<p class="excerpt" v-if="book.imageURL === ''">{{book.excerpt}}</p>
@ -78,7 +78,7 @@
<a title="Edit tags">
<i class="fas fa-tags"></i>
</a>
<a title="Delete bookmark">
<a title="Delete bookmark" @click="showDialogDelete([idx])">
<i class="fas fa-trash-alt"></i>
</a>
<a title="Update cache">
@ -86,6 +86,7 @@
</a>
</div>
</div>
<div></div>
</div>
</div>
<yla-dialog v-bind="dialog"></yla-dialog>
@ -119,7 +120,7 @@
.then((response) => {
this.loading = false;
this.bookmarks = response.data;
console.log(JSON.stringify(response.data));
console.log(JSON.stringify(response.data, '', ' '));
})
.catch((error) => {
var errorMsg = (error.response ? error.response.data : error.message).trim();
@ -130,22 +131,92 @@
showDialogAdd() {
this.showDialog({
title: 'New Bookmark',
content: 'Save an URL to bookmark',
content: 'Create a new bookmark',
fields: [{
name: 'url',
label: 'http://...',
label: 'Url, start with http://...',
}, {
name: 'tags',
label: 'Space separated tags (optional)'
}, {
name: 'title',
label: 'Custom title (optional)'
}, {
name: 'excerpt',
label: 'Custom excerpt (optional)',
type: 'area'
}],
mainText: 'OK',
secondText: 'Cancel',
mainClick: (data) => {
// Prepare tags
var tags = data.tags
.toLowerCase()
.split(/\s+/g).map(tag => {
return {
name: tag
};
});
// Send data
this.dialog.loading = true;
rest.post('/api/bookmarks', {
url: data.url,
url: data.url.trim(),
title: data.title.trim(),
excerpt: data.excerpt.trim(),
tags: tags
})
.then((response) => {
this.dialog.loading = false;
this.dialog.visible = false;
this.bookmarks.unshift(response.data);
this.bookmarks.splice(0, 0, response.data);
})
.catch((error) => {
var errorMsg = (error.response ? error.response.data : error.message).trim();
this.showErrorDialog(errorMsg);
});
}
});
},
showDialogDelete(indices) {
// Check and prepare indices
if (!(indices instanceof Array)) return;
if (indices.length === 0) return;
indices.sort();
// Create title andd content
var title = "Delete Bookmarks",
content = "Delete the selected bookmarks ? This action is irreversible.";
if (indices.length === 1) {
var book = this.bookmarks[indices[0]];
title = "Delete Bookmark";
content = "Are you sure ? This action is irreversible.";
}
// Get list of bookmark ID
var listID = [];
for (var i = 0; i < indices.length; i++) {
listID.push('' + this.bookmarks[indices[i]].id);
}
// Show dialog
this.showDialog({
title: title,
content: content,
mainText: 'Yes',
secondText: 'No',
mainClick: () => {
this.dialog.loading = true;
rest.delete('/api/bookmarks/', {
data: listID
})
.then((response) => {
this.dialog.loading = false;
this.dialog.visible = false;
for (var i = indices.length - 1; i >= 0; i--) {
this.bookmarks.splice(indices[i], 1);
}
})
.catch((error) => {
var errorMsg = (error.response ? error.response.data : error.message).trim();
@ -157,7 +228,7 @@
getHostname(url) {
parser = document.createElement('a');
parser.href = url;
return parser.hostname;
return parser.hostname.replace(/^www\./g, '');
}
},
mounted() {

View file

@ -11,7 +11,17 @@ var YlaDialog = function () {
<p class="yla-dialog__content">{{content}}</p>
<template v-for="(field,index) in formFields">
<p v-if="showLabel">{{field.label}} :</p>
<input :style="{gridColumnEnd: showLabel ? null : 'span 2'}"
<textarea v-if="field.type === 'area'"
:style="{gridColumnEnd: showLabel ? null : 'span 2'}"
:placeholder="field.label"
:tabindex="index+1"
ref="input"
v-model="field.value"
@focus="$event.target.select()"
@keyup="handleInput(index)">
</textarea>
<input v-else
:style="{gridColumnEnd: showLabel ? null : 'span 2'}"
:type="fieldType(field)"
:placeholder="field.label"
:tabindex="index+1"

View file

@ -204,7 +204,12 @@ a {
grid-template-rows: auto;
grid-template-columns: repeat(4, 1fr);
grid-gap: 16px;
padding: 16px;
padding: 16px 16px 0;
>div:last-child {
grid-column-start: 1;
grid-column-end: -1;
height: 1px;
}
.bookmark {
display: flex;
flex-flow: column nowrap;
@ -212,10 +217,8 @@ a {
border: 1px solid @border;
background-color: @contentBg;
height: 100%;
&:last-child {
margin-bottom: 16px;
}
&:hover {
&:hover,
&:focus {
.bookmark-menu>a {
display: block;
}
@ -224,30 +227,37 @@ a {
display: block;
position: relative;
flex: 1;
&:hover,
&:focus {
.title {
color: @main;
cursor: default;
&[href] {
cursor: pointer;
&:hover,
&:focus {
.title {
color: @main;
}
}
}
>*:not(:last-child) {
margin-bottom: 8px;
}
img {
max-width: 100%;
margin-bottom: 8px;
}
.title {
text-overflow: ellipsis;
word-wrap: break-word;
overflow: hidden;
font-size: 1.2em;
line-height: 1.3em;
max-height: 5.2em;
font-weight: 600;
padding: 0 16px;
color: @color;
&:first-child {
padding-top: 16px;
margin-top: 16px;
}
}
.excerpt {
color: @color;
padding: 0 16px;
padding: 8px 16px 0;
text-overflow: ellipsis;
word-wrap: break-word;
overflow: hidden;

View file

@ -48,7 +48,6 @@
max-height: 100%;
min-height: 80px;
min-width: 0;
font-size: 0.9em;
overflow: auto;
grid-template-columns: max-content 1fr;
align-items: baseline;
@ -57,11 +56,15 @@
grid-column-start: 1;
grid-column-end: 3;
align-self: baseline;
font-size: 0.9em;
}
>input {
>input,
>textarea {
color: @color;
padding: 8px;
border: 1px solid @border;
font-size: 0.9em;
min-height: 37px;
}
>.suggestion {
position: absolute;
@ -82,6 +85,7 @@
padding: 0 8px;
font-size: 0.9em;
font-weight: 600;
border-bottom: 1px dashed transparent;
&:hover {
color: @main;
}