The alert system is now fully functional.
This commit is contained in:
parent
cf480947d3
commit
c7d058fe90
4
data.sql
4
data.sql
|
@ -136,8 +136,8 @@ CREATE TABLE `activity_stream`(
|
||||||
|
|
||||||
CREATE TABLE `activity_subscriptions`(
|
CREATE TABLE `activity_subscriptions`(
|
||||||
`user` int not null,
|
`user` int not null,
|
||||||
`targetID` int not null,
|
`targetID` int not null, /* the ID of the element being acted upon */
|
||||||
`targetType` varchar(50) not null,
|
`targetType` varchar(50) not null, /* topic, post (calling it post here to differentiate it from the 'reply' event), forum, user */
|
||||||
`level` tinyint DEFAULT 0 not null /* 0: Mentions (aka the global default for any post), 1: Replies, 2: Everyone*/
|
`level` tinyint DEFAULT 0 not null /* 0: Mentions (aka the global default for any post), 1: Replies, 2: Everyone*/
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
21
mysql.go
21
mysql.go
|
@ -33,6 +33,9 @@ var update_forum_cache_stmt *sql.Stmt
|
||||||
var create_like_stmt *sql.Stmt
|
var create_like_stmt *sql.Stmt
|
||||||
var add_likes_to_topic_stmt *sql.Stmt
|
var add_likes_to_topic_stmt *sql.Stmt
|
||||||
var add_likes_to_reply_stmt *sql.Stmt
|
var add_likes_to_reply_stmt *sql.Stmt
|
||||||
|
var add_activity_stmt *sql.Stmt
|
||||||
|
var notify_watchers_stmt *sql.Stmt
|
||||||
|
var notify_one_stmt *sql.Stmt
|
||||||
var edit_topic_stmt *sql.Stmt
|
var edit_topic_stmt *sql.Stmt
|
||||||
var edit_reply_stmt *sql.Stmt
|
var edit_reply_stmt *sql.Stmt
|
||||||
var delete_reply_stmt *sql.Stmt
|
var delete_reply_stmt *sql.Stmt
|
||||||
|
@ -238,6 +241,24 @@ func init_database(err error) {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Print("Preparing add_activity statement.")
|
||||||
|
add_activity_stmt, err = db.Prepare("INSERT INTO activity_stream(actor,targetUser,event,elementType,elementID) VALUES(?,?,?,?,?)")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Print("Preparing notify_watchers statement.")
|
||||||
|
notify_watchers_stmt, err = db.Prepare("INSERT INTO activity_stream_matches(watcher, asid) SELECT activity_subscriptions.user, ? AS asid FROM activity_subscriptions LEFT JOIN activity_stream ON activity_subscriptions.targetType=activity_stream.elementType and activity_subscriptions.targetID=activity_stream.elementID")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Print("Preparing notify_one statement.")
|
||||||
|
notify_one_stmt, err = db.Prepare("INSERT INTO activity_stream_matches(watcher,asid) VALUES(?,?)")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Print("Preparing edit_topic statement.")
|
log.Print("Preparing edit_topic statement.")
|
||||||
edit_topic_stmt, err = db.Prepare("UPDATE topics SET title = ?, content = ?, parsed_content = ?, is_closed = ? WHERE tid = ?")
|
edit_topic_stmt, err = db.Prepare("UPDATE topics SET title = ?, content = ?, parsed_content = ?, is_closed = ? WHERE tid = ?")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -310,51 +310,33 @@ func build_forum_permissions() error {
|
||||||
|
|
||||||
func strip_invalid_preset(preset string) string {
|
func strip_invalid_preset(preset string) string {
|
||||||
switch(preset) {
|
switch(preset) {
|
||||||
case "all":
|
case "all","announce","members","staff","admins","archive":
|
||||||
case "announce":
|
|
||||||
case "members":
|
|
||||||
case "staff":
|
|
||||||
case "admins":
|
|
||||||
case "archive":
|
|
||||||
break
|
break
|
||||||
default:
|
default: return ""
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
return preset
|
return preset
|
||||||
}
|
}
|
||||||
|
|
||||||
func preset_to_lang(preset string) string {
|
func preset_to_lang(preset string) string {
|
||||||
switch(preset) {
|
switch(preset) {
|
||||||
case "all":
|
case "all": return ""//return "Everyone"
|
||||||
return ""//return "Everyone"
|
case "announce": return "Announcements"
|
||||||
case "announce":
|
case "members": return "Member Only"
|
||||||
return "Announcements"
|
case "staff": return "Staff Only"
|
||||||
case "members":
|
case "admins": return "Admin Only"
|
||||||
return "Member Only"
|
case "archive": return "Archive"
|
||||||
case "staff":
|
|
||||||
return "Staff Only"
|
|
||||||
case "admins":
|
|
||||||
return "Admin Only"
|
|
||||||
case "archive":
|
|
||||||
return "Archive"
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func preset_to_emoji(preset string) string {
|
func preset_to_emoji(preset string) string {
|
||||||
switch(preset) {
|
switch(preset) {
|
||||||
case "all":
|
case "all": return ""//return "Everyone"
|
||||||
return ""//return "Everyone"
|
case "announce": return "📣"
|
||||||
case "announce":
|
case "members": return "👪"
|
||||||
return "📣"
|
case "staff": return "👮"
|
||||||
case "members":
|
case "admins": return "👑"
|
||||||
return "👪"
|
case "archive": return "☠️"
|
||||||
case "staff":
|
|
||||||
return "👮"
|
|
||||||
case "admins":
|
|
||||||
return "👑"
|
|
||||||
case "archive":
|
|
||||||
return "☠️"
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
132
public/global.js
132
public/global.js
|
@ -8,6 +8,70 @@ function post_link(event)
|
||||||
$.ajax({ url: form_action, type: "POST", dataType: "json", data: {js: "1"} });
|
$.ajax({ url: form_action, type: "POST", dataType: "json", data: {js: "1"} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function load_alerts(menu_alerts)
|
||||||
|
{
|
||||||
|
menu_alerts.find(".alert_counter").text("");
|
||||||
|
$.ajax({
|
||||||
|
type: 'get',
|
||||||
|
dataType: 'json',
|
||||||
|
url:'/api/?action=get&module=alerts&format=json',
|
||||||
|
success: function(data) {
|
||||||
|
if("errmsg" in data) {
|
||||||
|
console.log(data.errmsg);
|
||||||
|
menu_alerts.find(".alertList").html("<div class='alertItem'>"+data.errmsg+"</div>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var alist = "";
|
||||||
|
for(var i in data.msgs) {
|
||||||
|
var msg = data.msgs[i];
|
||||||
|
var mmsg = msg.msg;
|
||||||
|
|
||||||
|
if("sub" in msg) {
|
||||||
|
for(var i = 0; i < msg.sub.length; i++) {
|
||||||
|
mmsg = mmsg.replace("\{"+i+"\}", msg.sub[i]);
|
||||||
|
console.log("Sub #" + i);
|
||||||
|
console.log(msg.sub[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(mmsg.length > 46) mmsg = mmsg.substring(0,43) + "...";
|
||||||
|
else if(mmsg.length > 35) size_dial = " smaller"; //9px
|
||||||
|
else size_dial = ""; // 10px
|
||||||
|
|
||||||
|
if("avatar" in msg) {
|
||||||
|
alist += "<div class='alertItem withAvatar' style='background-image:url(\""+msg.avatar+"\");'><a class='text"+size_dial+"' href=\""+msg.path+"\">"+mmsg+"</a></div>";
|
||||||
|
console.log(msg.avatar);
|
||||||
|
} else {
|
||||||
|
alist += "<div class='alertItem'><a href=\""+msg.path+"\" class='text"+size_dial+"'>"+mmsg+"</a></div>";
|
||||||
|
}
|
||||||
|
console.log(msg);
|
||||||
|
//console.log(mmsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(alist == "") {
|
||||||
|
alist = "<div class='alertItem'>You don't have any alerts</div>"
|
||||||
|
}
|
||||||
|
menu_alerts.find(".alertList").html(alist);
|
||||||
|
if(data.msgs.length != 0) {
|
||||||
|
menu_alerts.find(".alert_counter").text(data.msgs.length);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(magic,theStatus,error) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(magic.responseText);
|
||||||
|
if("errmsg" in data)
|
||||||
|
{
|
||||||
|
console.log(data.errmsg);
|
||||||
|
errtxt = data.errmsg;
|
||||||
|
}
|
||||||
|
else errtxt = "Unable to get the alerts"
|
||||||
|
} catch(e) { errtxt = "Unable to get the alerts"; }
|
||||||
|
menu_alerts.find(".alertList").html("<div class='alertItem'>"+errtxt+"</div>");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
$(".open_edit").click(function(event){
|
$(".open_edit").click(function(event){
|
||||||
//console.log("Clicked on edit");
|
//console.log("Clicked on edit");
|
||||||
|
@ -162,63 +226,19 @@ $(document).ready(function(){
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".menu_alerts").click(function(event) {
|
$('body').click(function() {
|
||||||
if($(this).hasClass("selectedAlert")) return;
|
$(".selectedAlert").removeClass("selectedAlert");
|
||||||
var menu_alerts = $(this);
|
|
||||||
|
|
||||||
this.className += " selectedAlert";
|
|
||||||
$.ajax({
|
|
||||||
type: 'get',
|
|
||||||
dataType: 'json',
|
|
||||||
url:'/api/?action=get&module=alerts&format=json',
|
|
||||||
success: function(data) {
|
|
||||||
if("errmsg" in data) {
|
|
||||||
console.log(data.errmsg);
|
|
||||||
menu_alerts.find(".alertList").html("<div class='alertItem'>"+data.errmsg+"</div>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var alist = "";
|
|
||||||
for(var i in data.msgs) {
|
|
||||||
var msg = data.msgs[i];
|
|
||||||
var mmsg = msg.msg;
|
|
||||||
|
|
||||||
if("sub" in msg) {
|
|
||||||
for(var i = 0; i < msg.sub.length; i++) {
|
|
||||||
mmsg = mmsg.replace("\{"+i+"\}", msg.sub[i]);
|
|
||||||
console.log("Sub #" + i);
|
|
||||||
console.log(msg.sub[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if("avatar" in msg) {
|
|
||||||
alist += "<div class='alertItem withAvatar' style='background-image:url(\""+msg.avatar+"\");'><div class='text'>"+mmsg+"</div></div>";
|
|
||||||
console.log(msg.avatar);
|
|
||||||
} else {
|
|
||||||
alist += "<div class='alertItem'>"+mmsg+"</div>";
|
|
||||||
}
|
|
||||||
console.log(msg);
|
|
||||||
console.log(mmsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(alist == "") {
|
|
||||||
alist = "<div class='alertItem'>You don't have any alerts</div>"
|
|
||||||
}
|
|
||||||
menu_alerts.find(".alertList").html(alist);
|
|
||||||
},
|
|
||||||
error: function(magic,theStatus,error) {
|
|
||||||
try {
|
|
||||||
var data = JSON.parse(magic.responseText);
|
|
||||||
if("errmsg" in data)
|
|
||||||
{
|
|
||||||
console.log(data.errmsg);
|
|
||||||
errtxt = data.errmsg;
|
|
||||||
}
|
|
||||||
else errtxt = "Unable to get the alerts"
|
|
||||||
} catch(e) { errtxt = "Unable to get the alerts"; }
|
|
||||||
menu_alerts.find(".alertList").html("<div class='alertItem'>"+errtxt+"</div>");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(".menu_alerts").ready(function(){
|
||||||
|
load_alerts($(this));
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".menu_alerts").click(function(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if($(this).hasClass("selectedAlert")) return;
|
||||||
|
this.className += " selectedAlert";
|
||||||
|
load_alerts($(this));
|
||||||
});
|
});
|
||||||
|
|
||||||
this.onkeyup = function(event){
|
this.onkeyup = function(event){
|
||||||
|
|
65
routes.go
65
routes.go
|
@ -727,7 +727,8 @@ func route_like_topic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
var words int
|
var words int
|
||||||
var fid int
|
var fid int
|
||||||
err = db.QueryRow("select parentID, words from topics where tid = ?", tid).Scan(&fid,&words)
|
var createdBy int
|
||||||
|
err = db.QueryRow("select parentID, words, createdBy from topics where tid = ?", tid).Scan(&fid,&words,&createdBy)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
PreError("The requested topic doesn't exist.",w,r)
|
PreError("The requested topic doesn't exist.",w,r)
|
||||||
return
|
return
|
||||||
|
@ -754,6 +755,15 @@ func route_like_topic(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = users.CascadeGet(createdBy)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
LocalError("The target user doesn't exist",w,r,user)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
InternalError(err,w,r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
//score := words_to_score(words,true)
|
//score := words_to_score(words,true)
|
||||||
score := 1
|
score := 1
|
||||||
_, err = create_like_stmt.Exec(score,tid,"topics",user.ID)
|
_, err = create_like_stmt.Exec(score,tid,"topics",user.ID)
|
||||||
|
@ -768,6 +778,28 @@ func route_like_topic(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res, err := add_activity_stmt.Exec(user.ID,createdBy,"like","topic",tid)
|
||||||
|
if err != nil {
|
||||||
|
InternalError(err,w,r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastId, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
InternalError(err,w,r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*_, err = notify_watchers_stmt.Exec(lastId)
|
||||||
|
if err != nil {
|
||||||
|
InternalError(err,w,r)
|
||||||
|
return
|
||||||
|
}*/
|
||||||
|
_, err = notify_one_stmt.Exec(createdBy,lastId)
|
||||||
|
if err != nil {
|
||||||
|
InternalError(err,w,r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w,r,"/topic/" + strconv.Itoa(tid),http.StatusSeeOther)
|
http.Redirect(w,r,"/topic/" + strconv.Itoa(tid),http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -786,7 +818,8 @@ func route_reply_like_submit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
var tid int
|
var tid int
|
||||||
var words int
|
var words int
|
||||||
err = db.QueryRow("select tid, words from replies where rid = ?", rid).Scan(&tid, &words)
|
var createdBy int
|
||||||
|
err = db.QueryRow("select tid, words, createdBy from replies where rid = ?", rid).Scan(&tid, &words, &createdBy)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
PreError("You can't like something which doesn't exist!",w,r)
|
PreError("You can't like something which doesn't exist!",w,r)
|
||||||
return
|
return
|
||||||
|
@ -823,6 +856,15 @@ func route_reply_like_submit(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = users.CascadeGet(createdBy)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
LocalError("The target user doesn't exist",w,r,user)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
InternalError(err,w,r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
//score := words_to_score(words,false)
|
//score := words_to_score(words,false)
|
||||||
score := 1
|
score := 1
|
||||||
_, err = create_like_stmt.Exec(score,rid,"replies",user.ID)
|
_, err = create_like_stmt.Exec(score,rid,"replies",user.ID)
|
||||||
|
@ -837,6 +879,23 @@ func route_reply_like_submit(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res, err := add_activity_stmt.Exec(user.ID,createdBy,"like","post",rid)
|
||||||
|
if err != nil {
|
||||||
|
InternalError(err,w,r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastId, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
InternalError(err,w,r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = notify_one_stmt.Exec(createdBy,lastId)
|
||||||
|
if err != nil {
|
||||||
|
InternalError(err,w,r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w,r,"/topic/" + strconv.Itoa(tid),http.StatusSeeOther)
|
http.Redirect(w,r,"/topic/" + strconv.Itoa(tid),http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1748,7 +1807,7 @@ func route_api(w http.ResponseWriter, r *http.Request) {
|
||||||
LocalErrorJS("Unable to find the target reply or parent topic",w,r)
|
LocalErrorJS("Unable to find the target reply or parent topic",w,r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
url = build_topic_url(elementID)
|
url = build_topic_url(topic.ID)
|
||||||
area = topic.Title
|
area = topic.Title
|
||||||
if targetUser_id == user.ID {
|
if targetUser_id == user.ID {
|
||||||
post_act = " your post in"
|
post_act = " your post in"
|
||||||
|
|
12
templates.go
12
templates.go
|
@ -743,14 +743,10 @@ func (c *CTemplateSet) compile_if_varsub(varname string, varholder string, templ
|
||||||
func (c *CTemplateSet) compile_boolsub(varname string, varholder string, template_name string, val reflect.Value) string {
|
func (c *CTemplateSet) compile_boolsub(varname string, varholder string, template_name string, val reflect.Value) string {
|
||||||
out, val := c.compile_if_varsub(varname, varholder, template_name, val)
|
out, val := c.compile_if_varsub(varname, varholder, template_name, val)
|
||||||
switch val.Kind() {
|
switch val.Kind() {
|
||||||
case reflect.Int:
|
case reflect.Int: out += " > 0"
|
||||||
out += " > 0"
|
case reflect.Bool: // Do nothing
|
||||||
case reflect.Bool:
|
case reflect.String: out += " != \"\""
|
||||||
// Do nothing
|
case reflect.Int64: out += " > 0"
|
||||||
case reflect.String:
|
|
||||||
out += " != \"\""
|
|
||||||
case reflect.Int64:
|
|
||||||
out += " > 0"
|
|
||||||
default:
|
default:
|
||||||
fmt.Println(varname)
|
fmt.Println(varname)
|
||||||
fmt.Println(varholder)
|
fmt.Println(varholder)
|
||||||
|
|
|
@ -106,6 +106,10 @@ li:hover
|
||||||
.selectedAlert:hover {
|
.selectedAlert:hover {
|
||||||
background: white;
|
background: white;
|
||||||
color: black;
|
color: black;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.selectedAlert .alert_counter {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.menu_alerts .alertList {
|
.menu_alerts .alertList {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -117,7 +121,7 @@ li:hover
|
||||||
background: white;
|
background: white;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
width: 135px;
|
width: 156px;
|
||||||
right: -15px;
|
right: -15px;
|
||||||
border-top: 1px solid #ccc;
|
border-top: 1px solid #ccc;
|
||||||
border-left: 1px solid #ccc;
|
border-left: 1px solid #ccc;
|
||||||
|
@ -130,21 +134,25 @@ li:hover
|
||||||
}
|
}
|
||||||
.alertItem.withAvatar {
|
.alertItem.withAvatar {
|
||||||
/*background-image: url('/uploads/avatar_1.jpg');*/
|
/*background-image: url('/uploads/avatar_1.jpg');*/
|
||||||
background-size: auto 56px;
|
background-size: 36px;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
text-align: right;
|
text-align: center;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
|
padding-left: 42px;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
}
|
}
|
||||||
.alertItem.withAvatar:not(:last-child) {
|
.alertItem.withAvatar:not(:last-child) {
|
||||||
border-bottom: 1px solid rgb(230,230,230);
|
border-bottom: 1px solid rgb(230,230,230);
|
||||||
}
|
}
|
||||||
.alertItem.withAvatar .text {
|
.alertItem .text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
float: right;
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
width: 100%;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.alertItem .text.smaller {
|
||||||
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#footer
|
#footer
|
||||||
|
@ -967,10 +975,7 @@ blockquote p
|
||||||
border: 1px solid rgba(90,90,90,0.75);
|
border: 1px solid rgba(90,90,90,0.75);
|
||||||
transition: transform 0.7s;
|
transition: transform 0.7s;
|
||||||
}
|
}
|
||||||
ul:hover
|
ul:hover { transform: rotateX(-15deg); }
|
||||||
{
|
|
||||||
transform: rotateX(-15deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
li
|
li
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in New Issue