mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
67f0d19a65 | |||
03b104a390 | |||
4a3fe48cce | |||
1033343702 | |||
31c462c275 | |||
0a7ebc4dc7 | |||
91768cf927 | |||
e967a74e36 | |||
8e6719f0b7 | |||
bcbd6236df | |||
d1cbabf662 | |||
ad4d251154 | |||
2bb3b886c2 | |||
4f183ed637 | |||
b66f9b5cf5 | |||
bf7f93fcd4 | |||
36c96dafca |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@ -45,6 +45,6 @@ jobs:
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:alpine
|
||||
ghcr.io/${{ github.repository }}:${{ env.GIT_TAG }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cache-from: type=registry,ref=n1try/wakapi:buildcache-alpine
|
||||
cache-to: type=registry,ref=n1try/wakapi:buildcache-alpine,mode=max
|
||||
|
26
Caddyfile
Normal file
26
Caddyfile
Normal file
@ -0,0 +1,26 @@
|
||||
wakapi.yourdomain.tld {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=2592000; includeSubDomains"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/wakapi.dev.access.log
|
||||
format single_field common_log
|
||||
}
|
||||
|
||||
reverse_proxy http://[::1]:3000
|
||||
|
||||
@api path_regexp "^/api.*"
|
||||
@notapi not path_regexp "^/api.*"
|
||||
|
||||
push @notapi /assets/vendor/source-sans-3.css
|
||||
push @notapi /assets/css/app.dist.css
|
||||
push @notapi /assets/vendor/petite-vue.min.js
|
||||
push @notapi /assets/vendor/chart.min.js
|
||||
push @notapi /assets/vendor/iconify.basic.min.js
|
||||
push @notapi /assets/js/icons.dist.js
|
||||
push @notapi /assets/js/base.js
|
||||
push @notapi /assets/images/logo.svg
|
||||
}
|
23
Dockerfile
23
Dockerfile
@ -1,19 +1,26 @@
|
||||
# Build Stage
|
||||
# To build locally: docker buildx build . -t wakapi --load
|
||||
|
||||
FROM golang:1.16-alpine AS build-env
|
||||
# Preparation to save some time
|
||||
FROM --platform=$BUILDPLATFORM golang:1.17-alpine AS prep-env
|
||||
WORKDIR /src
|
||||
|
||||
# Required for go-sqlite3
|
||||
RUN apk add gcc musl-dev
|
||||
|
||||
ADD ./go.mod .
|
||||
RUN go mod download
|
||||
ADD . .
|
||||
|
||||
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
|
||||
chmod +x wait-for-it.sh
|
||||
|
||||
ADD . .
|
||||
RUN go build -o wakapi
|
||||
# Build Stage
|
||||
FROM golang:1.17-alpine AS build-env
|
||||
|
||||
# Required for go-sqlite3
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
WORKDIR /src
|
||||
COPY --from=prep-env /src .
|
||||
|
||||
RUN go build -v -o wakapi
|
||||
|
||||
WORKDIR /app
|
||||
RUN cp /src/wakapi . && \
|
||||
@ -31,7 +38,7 @@ RUN cp /src/wakapi . && \
|
||||
FROM alpine:3
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk update && apk add bash ca-certificates tzdata && rm -rf /var/cache/apk
|
||||
RUN apk add --no-cache bash ca-certificates tzdata
|
||||
|
||||
# See README.md and config.default.yml for all config options
|
||||
ENV ENVIRONMENT prod
|
||||
|
@ -29,7 +29,7 @@
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/assets/images/screenshot.png" width="500px">
|
||||
<img src="static/assets/images/screenshot.webp" width="500px">
|
||||
</p>
|
||||
|
||||
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$WAKAPI_DB_TYPE" == "sqlite3" ] || [ "$WAKAPI_DB_TYPE" == "" ]; then
|
||||
./wakapi
|
||||
exec ./wakapi
|
||||
else
|
||||
echo "Waiting for database to come up"
|
||||
./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
|
||||
fi
|
||||
exec ./wait-for-it.sh "$WAKAPI_DB_HOST:$WAKAPI_DB_PORT" -s -t 60 -- ./wakapi
|
||||
fi
|
||||
|
@ -29,6 +29,11 @@ func (m *AliasServiceMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) GetByUserAndType(s string, u uint8) ([]*models.Alias, error) {
|
||||
args := m.Called(s, u)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) GetByUserAndKeyAndType(s string, s2 string, u uint8) ([]*models.Alias, error) {
|
||||
args := m.Called(s, s2, u)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
|
@ -17,7 +17,7 @@ import (
|
||||
|
||||
const (
|
||||
intervalPattern = `interval:([a-z0-9_]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-\s]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine|label):([_a-zA-Z0-9-\s\.]+)`
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
|
43
routes/compat/shields/v1/badge_test.go
Normal file
43
routes/compat/shields/v1/badge_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBadgeHandler_EntityPattern(t *testing.T) {
|
||||
type test struct {
|
||||
test string
|
||||
key string
|
||||
val string
|
||||
}
|
||||
|
||||
pathPrefix := "/compat/shields/v1/current/today/"
|
||||
|
||||
tests := []test{
|
||||
{test: pathPrefix + "project:wakapi", key: "project", val: "wakapi"},
|
||||
{test: pathPrefix + "os:Linux", key: "os", val: "Linux"},
|
||||
{test: pathPrefix + "editor:VSCode", key: "editor", val: "VSCode"},
|
||||
{test: pathPrefix + "language:Java", key: "language", val: "Java"},
|
||||
{test: pathPrefix + "machine:devmachine", key: "machine", val: "devmachine"},
|
||||
{test: pathPrefix + "label:work", key: "label", val: "work"},
|
||||
{test: pathPrefix + "foo:bar", key: "", val: ""}, // invalid entity
|
||||
{test: pathPrefix + "project:01234", key: "project", val: "01234"}, // digits only
|
||||
{test: pathPrefix + "project:anchr-web-ext", key: "project", val: "anchr-web-ext"}, // with dashes
|
||||
{test: pathPrefix + "project:wakapi v2", key: "project", val: "wakapi v2"}, // with blank space
|
||||
{test: pathPrefix + "project:project", key: "project", val: "project"},
|
||||
{test: pathPrefix + "project:Anchr-Android_v2.0", key: "project", val: "Anchr-Android_v2.0"}, // all the way
|
||||
}
|
||||
|
||||
sut := regexp.MustCompile(entityFilterPattern)
|
||||
|
||||
for _, tc := range tests {
|
||||
var key, val string
|
||||
if groups := sut.FindStringSubmatch(tc.test); len(groups) > 2 {
|
||||
key, val = groups[1], groups[2]
|
||||
}
|
||||
assert.Equal(t, tc.key, key)
|
||||
assert.Equal(t, tc.val, val)
|
||||
}
|
||||
}
|
@ -10,6 +10,7 @@ import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/services/imports"
|
||||
"github.com/muety/wakapi/utils"
|
||||
@ -669,12 +670,11 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
||||
})
|
||||
|
||||
// projects
|
||||
projects, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
|
||||
projects, err := routeutils.GetEffectiveProjectsList(user, h.heartbeatSrvc, h.aliasSrvc)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while fetching projects - %v", err)
|
||||
return &view.SettingsViewModel{Error: criticalError}
|
||||
}
|
||||
sort.Strings(projects)
|
||||
|
||||
return &view.SettingsViewModel{
|
||||
User: user,
|
||||
|
53
routes/utils/project_utils.go
Normal file
53
routes/utils/project_utils.go
Normal file
@ -0,0 +1,53 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// GetEffectiveProjectsList returns the user's projects, including all alias targets and excluding all remapped project names (alias sources)
|
||||
// Example: "A" mapped to "AB" using an alias
|
||||
// -> "A" itself should not appear as a project anymore
|
||||
// -> Instead, the "virtual" project "AB" shall appear
|
||||
// See https://github.com/muety/wakapi/issues/231
|
||||
func GetEffectiveProjectsList(user *models.User, heartbeatSrvc services.IHeartbeatService, aliasSrvc services.IAliasService) ([]string, error) {
|
||||
projectsMap := make(map[string]bool) // proper sets as part of stdlib would be nice...
|
||||
|
||||
// extract actual projects from heartbeats
|
||||
realProjects, err := heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
// create a "set" / lookup table
|
||||
for _, p := range realProjects {
|
||||
projectsMap[p] = true
|
||||
}
|
||||
|
||||
// fetch aliases
|
||||
projectAliases, err := aliasSrvc.GetByUserAndType(user.ID, models.SummaryProject)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
// remove alias values (source of a mapping)
|
||||
// add alias key (target of a mapping) instead
|
||||
for _, a := range projectAliases {
|
||||
if projectsMap[a.Value] {
|
||||
projectsMap[a.Value] = false
|
||||
}
|
||||
projectsMap[a.Key] = true
|
||||
}
|
||||
|
||||
projects := make([]string, 0, len(projectsMap))
|
||||
for key, val := range projectsMap {
|
||||
if !val {
|
||||
continue
|
||||
}
|
||||
projects = append(projects, key)
|
||||
}
|
||||
|
||||
sort.Strings(projects)
|
||||
return projects, nil
|
||||
}
|
@ -56,21 +56,18 @@ func (srv *AliasService) GetByUser(userId string) ([]*models.Alias, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetByUserAndType(userId string, summaryType uint8) ([]*models.Alias, error) {
|
||||
check := func(a *models.Alias) bool {
|
||||
return a.Type == summaryType
|
||||
}
|
||||
return srv.getFiltered(userId, check)
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetByUserAndKeyAndType(userId, key string, summaryType uint8) ([]*models.Alias, error) {
|
||||
if !srv.IsInitialized(userId) {
|
||||
srv.MayInitializeUser(userId)
|
||||
}
|
||||
if aliases, ok := userAliases.Load(userId); ok {
|
||||
filteredAliases := make([]*models.Alias, 0, len(aliases.([]*models.Alias)))
|
||||
for _, a := range aliases.([]*models.Alias) {
|
||||
if a.Key == key && a.Type == summaryType {
|
||||
filteredAliases = append(filteredAliases, a)
|
||||
}
|
||||
}
|
||||
return filteredAliases, nil
|
||||
} else {
|
||||
return nil, errors.New(fmt.Sprintf("no user aliases loaded for user %s", userId))
|
||||
check := func(a *models.Alias) bool {
|
||||
return a.Key == key && a.Type == summaryType
|
||||
}
|
||||
return srv.getFiltered(userId, check)
|
||||
}
|
||||
|
||||
func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, value string) (string, error) {
|
||||
@ -94,7 +91,11 @@ func (srv *AliasService) Create(alias *models.Alias) (*models.Alias, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// manually update cache
|
||||
srv.updateCache(alias, false)
|
||||
// reload entire cache (async, though)
|
||||
go srv.MayInitializeUser(alias.UserID)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@ -103,7 +104,14 @@ func (srv *AliasService) Delete(alias *models.Alias) error {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
err := srv.repository.Delete(alias.ID)
|
||||
|
||||
// manually update cache
|
||||
if err == nil {
|
||||
srv.updateCache(alias, false)
|
||||
}
|
||||
// reload entire cache (async, though)
|
||||
go srv.MayInitializeUser(alias.UserID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@ -120,9 +128,53 @@ func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
|
||||
|
||||
err := srv.repository.DeleteBatch(ids)
|
||||
|
||||
// manually update cache
|
||||
if err == nil {
|
||||
for _, a := range aliases {
|
||||
srv.updateCache(a, true)
|
||||
}
|
||||
}
|
||||
// reload entire cache (async, though)
|
||||
for k := range affectedUsers {
|
||||
go srv.MayInitializeUser(k)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *AliasService) updateCache(reason *models.Alias, removal bool) {
|
||||
if !removal {
|
||||
if aliases, ok := userAliases.Load(reason.UserID); ok {
|
||||
updatedAliases := aliases.([]*models.Alias)
|
||||
updatedAliases = append(updatedAliases, reason)
|
||||
userAliases.Store(reason.UserID, updatedAliases)
|
||||
}
|
||||
} else {
|
||||
if aliases, ok := userAliases.Load(reason.UserID); ok {
|
||||
updatedAliases := make([]*models.Alias, 0, len(aliases.([]*models.Alias))) // if we only had generics...
|
||||
for _, a := range aliases.([]*models.Alias) {
|
||||
if a.ID != reason.ID {
|
||||
updatedAliases = append(updatedAliases, a)
|
||||
}
|
||||
}
|
||||
userAliases.Store(reason.UserID, updatedAliases)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AliasService) getFiltered(userId string, check func(alias *models.Alias) bool) ([]*models.Alias, error) {
|
||||
if !srv.IsInitialized(userId) {
|
||||
srv.MayInitializeUser(userId)
|
||||
}
|
||||
if aliases, ok := userAliases.Load(userId); ok {
|
||||
filteredAliases := make([]*models.Alias, 0, len(aliases.([]*models.Alias)))
|
||||
for _, a := range aliases.([]*models.Alias) {
|
||||
if check(a) {
|
||||
filteredAliases = append(filteredAliases, a)
|
||||
}
|
||||
}
|
||||
return filteredAliases, nil
|
||||
} else {
|
||||
return nil, errors.New(fmt.Sprintf("no user aliases loaded for user %s", userId))
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ type IAliasService interface {
|
||||
IsInitialized(string) bool
|
||||
InitializeUser(string) error
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
GetByUserAndType(string, uint8) ([]*models.Alias, error)
|
||||
GetByUserAndKeyAndType(string, string, uint8) ([]*models.Alias, error)
|
||||
GetAliasOrDefault(string, uint8, string) (string, error)
|
||||
}
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 34 KiB |
BIN
static/assets/images/screenshot.webp
Normal file
BIN
static/assets/images/screenshot.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
@ -1,4 +1,3 @@
|
||||
const LEGEND_MAX_ENTRIES = 9
|
||||
// dirty hack to vertically align legends across multiple charts
|
||||
// however, without monospace font, it's still not perfectly aligned
|
||||
// waiting for https://github.com/chartjs/Chart.js/discussions/9890
|
||||
@ -31,8 +30,8 @@ let topNPickers = [...document.getElementsByClassName('top-picker')]
|
||||
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
|
||||
topNPickers.forEach(e => {
|
||||
const idx = parseInt(e.attributes['data-entity'].value)
|
||||
e.max = Math.min(data[idx].length, 10)
|
||||
e.value = e.max
|
||||
e.max = data[idx].length
|
||||
e.value = Math.min(e.max, 9)
|
||||
})
|
||||
|
||||
let charts = []
|
||||
@ -77,7 +76,7 @@ function draw(subselection) {
|
||||
function filterLegendItem(item) {
|
||||
item.text = item.text.length > LEGEND_CHARACTERS ? item.text.slice(0, LEGEND_CHARACTERS - 3).padEnd(LEGEND_CHARACTERS, '.') : item.text
|
||||
item.text = item.text.padEnd(LEGEND_CHARACTERS + 3)
|
||||
return item.index < LEGEND_MAX_ENTRIES
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldUpdate(index) {
|
||||
|
@ -46,7 +46,7 @@ func Add(i, j int) int {
|
||||
}
|
||||
|
||||
func ParseUserAgent(ua string) (string, string, error) {
|
||||
re := regexp.MustCompile(`(?iU)^wakatime\/v?[\d+.]+\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
|
||||
re := regexp.MustCompile(`(?iU)^wakatime\/(?:v?[\d+.]+|unset)\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
|
||||
groups := re.FindAllStringSubmatch(ua, -1)
|
||||
if len(groups) == 0 || len(groups[0]) != 3 {
|
||||
return "", "", errors.New("failed to parse user agent string")
|
||||
|
@ -43,6 +43,12 @@ func TestCommon_ParseUserAgent(t *testing.T) {
|
||||
"emacs",
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"wakatime/unset (linux-5.11.0-44-generic-x86_64) go1.16.13 emacs-wakatime/1.0.2",
|
||||
"linux",
|
||||
"emacs",
|
||||
nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
@ -1 +1 @@
|
||||
2.0.0-RC1
|
||||
2.0.1
|
@ -5,7 +5,7 @@
|
||||
<meta property="og:title" content="Wakapi - Coding Statistics" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="Wakapi is an open-source tool that helps you keep track of the time you have spent coding on different projects in different programming languages and more. Ideal for statistics freaks and anyone else.">
|
||||
<meta property="og:image" content="assets/images/screenshot.png" />
|
||||
<meta property="og:image" content="assets/images/screenshot.webp" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/images/favicon-16x16.png">
|
||||
|
@ -58,7 +58,7 @@
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center my-8">
|
||||
<img alt="App screenshot" src="assets/images/screenshot.png">
|
||||
<img alt="App screenshot" src="assets/images/screenshot.webp">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-10">
|
||||
|
@ -266,33 +266,33 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-8"></div>
|
||||
|
||||
{{ if .Projects }}
|
||||
<h3 class="inline-block font-semibold text-gray-300">Add Label</h3>
|
||||
<form action="" method="post">
|
||||
<input type="hidden" name="action" value="add_label">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center mt-2 w-full text-gray-500 text-sm space-x-4">
|
||||
<select name="key" id="select-project"
|
||||
class="select-default flex-grow">
|
||||
{{ range $i, $p := .Projects }}
|
||||
<option value="{{ $p }}">{{ $p }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<input class="input-default"
|
||||
type="text" id="label-value"
|
||||
name="value" placeholder="Label" minlength="1" required>
|
||||
<button type="submit" class="btn-primary">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{ else }}
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{ if .Projects }}
|
||||
<h3 class="inline-block font-semibold text-gray-300">Add Label</h3>
|
||||
<form action="" method="post">
|
||||
<input type="hidden" name="action" value="add_label">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center mt-2 w-full text-gray-500 text-sm space-x-4">
|
||||
<select name="key" id="select-project"
|
||||
class="select-default flex-grow">
|
||||
{{ range $i, $p := .Projects }}
|
||||
<option value="{{ $p }}">{{ $p }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<input class="input-default"
|
||||
type="text" id="label-value"
|
||||
name="value" placeholder="Label" minlength="1" required>
|
||||
<button type="submit" class="btn-primary">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{ else }}
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -476,7 +476,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-cloak id="integrations" class="tab flex flex-col space-y-4" v-if="isActive('integrations')">
|
||||
<div v-cloak id="integrations" class="tab flex flex-col space-y-4" v-show="isActive('integrations')">
|
||||
<form action="" method="post" class="w-full lg:w-3/4">
|
||||
<input type="hidden" name="action" value="toggle_wakatime">
|
||||
|
||||
|
Reference in New Issue
Block a user