Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note_extension
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note_extension
Commits
25b29d27
Commit
25b29d27
authored
Apr 29, 2026
by
Vũ Hoàng Anh
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: complete redesign — shadcn/ui dark mode, Canifa branding, Memos auth
parent
ba521e44
Changes
15
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
2011 additions
and
2233 deletions
+2011
-2233
manifest.json
extension/dist/.vite/manifest.json
+9
-9
popup-dYtSf2jU.css
extension/dist/assets/popup-dYtSf2jU.css
+0
-1
manifest.json
extension/dist/manifest.json
+5
-5
service-worker-loader.js
extension/dist/service-worker-loader.js
+1
-1
popup.html
extension/dist/src/popup/popup.html
+16
-19
manifest.json
extension/manifest.json
+3
-3
service-worker.ts
extension/src/background/service-worker.ts
+134
-156
NoteForm.tsx
extension/src/components/NoteForm.tsx
+170
-185
Settings.tsx
extension/src/components/Settings.tsx
+128
-182
TagSelector.tsx
extension/src/components/TagSelector.tsx
+129
-138
content-script.ts
extension/src/content/content-script.ts
+276
-340
popup.css
extension/src/popup/popup.css
+717
-618
popup.html
extension/src/popup/popup.html
+23
-26
popup.tsx
extension/src/popup/popup.tsx
+182
-258
api-client.ts
extension/src/shared/api-client.ts
+218
-292
No files found.
extension/dist/.vite/manifest.json
View file @
25b29d27
{
"../../../@crx/manifest"
:
{
"file"
:
"assets/crx-manifest.js-
SqlU4S0k
.js"
,
"file"
:
"assets/crx-manifest.js-
kDmztkte
.js"
,
"name"
:
"crx-manifest.js"
,
"src"
:
"../../../@crx/manifest"
,
"isEntry"
:
true
},
"_api-client-
Y5oDKlCr
.js"
:
{
"file"
:
"assets/api-client-
Y5oDKlCr
.js"
,
"_api-client-
CzjXLdoC
.js"
:
{
"file"
:
"assets/api-client-
CzjXLdoC
.js"
,
"name"
:
"api-client"
},
"src/background/service-worker.ts"
:
{
"file"
:
"assets/service-worker.ts-
DvMHIkFe
.js"
,
"file"
:
"assets/service-worker.ts-
C6gHU6BA
.js"
,
"name"
:
"service-worker.ts"
,
"src"
:
"src/background/service-worker.ts"
,
"isEntry"
:
true
,
"imports"
:
[
"_api-client-
Y5oDKlCr
.js"
"_api-client-
CzjXLdoC
.js"
]
},
"src/content/content-script.ts"
:
{
"file"
:
"assets/content-script.ts-
BWL85FVS
.js"
,
"file"
:
"assets/content-script.ts-
CTdB63jU
.js"
,
"name"
:
"content-script.ts"
,
"src"
:
"src/content/content-script.ts"
,
"isEntry"
:
true
},
"src/popup/popup.html"
:
{
"file"
:
"assets/popup-
DmXuB8QF
.js"
,
"file"
:
"assets/popup-
CSzsyzxK
.js"
,
"name"
:
"popup"
,
"src"
:
"src/popup/popup.html"
,
"isEntry"
:
true
,
"imports"
:
[
"_api-client-
Y5oDKlCr
.js"
"_api-client-
CzjXLdoC
.js"
],
"css"
:
[
"assets/popup-
dYtSf2jU
.css"
"assets/popup-
C6DDov6Y
.css"
]
}
}
\ No newline at end of file
extension/dist/assets/popup-dYtSf2jU.css
deleted
100644 → 0
View file @
ba521e44
:root
{
--background
:
oklch
(
.95
.015
75
);
--foreground
:
oklch
(
.25
.02
65
);
--card
:
oklch
(
.98
.008
80
);
--card-foreground
:
oklch
(
.22
.015
68
);
--popover
:
oklch
(
.98
.008
80
);
--popover-foreground
:
oklch
(
.25
.02
65
);
--primary
:
oklch
(
.45
.08
45
);
--primary-foreground
:
oklch
(
.98
.008
80
);
--secondary
:
oklch
(
.92
.025
70
);
--secondary-foreground
:
oklch
(
.35
.03
60
);
--muted
:
oklch
(
.9
.025
75
);
--muted-foreground
:
oklch
(
.5
.02
68
);
--accent
:
oklch
(
.88
.035
55
);
--accent-foreground
:
oklch
(
.25
.02
65
);
--destructive
:
oklch
(
.48
.15
25
);
--border
:
oklch
(
.88
.018
72
);
--input
:
oklch
(
.8
.03
75
);
--ring
:
oklch
(
.45
.08
45
);
--radius
:
12px
;
--success
:
oklch
(
.6
.15
145
);
--warning
:
oklch
(
.7
.12
75
);
--shadow-sm
:
0
1px
2px
oklch
(
0
0
0
/
.04
);
--shadow-md
:
0
4px
12px
oklch
(
0
0
0
/
.06
);
--shadow-lg
:
0
8px
24px
oklch
(
0
0
0
/
.08
);
--font-sans
:
"Inter"
,
ui-sans-serif
,
system-ui
,
-apple-system
,
BlinkMacSystemFont
,
"Segoe UI"
,
Roboto
,
sans-serif
}
[
data-theme
=
dark
]
{
--background
:
oklch
(
.16
.008
60
);
--foreground
:
oklch
(
.9
.012
75
);
--card
:
oklch
(
.2
.01
62
);
--card-foreground
:
oklch
(
.9
.012
75
);
--popover
:
oklch
(
.2
.01
62
);
--popover-foreground
:
oklch
(
.88
.01
72
);
--primary
:
oklch
(
.65
.1
45
);
--primary-foreground
:
oklch
(
.15
.008
60
);
--secondary
:
oklch
(
.26
.012
65
);
--secondary-foreground
:
oklch
(
.85
.01
72
);
--muted
:
oklch
(
.23
.01
62
);
--muted-foreground
:
oklch
(
.6
.015
70
);
--accent
:
oklch
(
.28
.015
55
);
--accent-foreground
:
oklch
(
.82
.012
68
);
--destructive
:
oklch
(
.55
.1
25
);
--border
:
oklch
(
.3
.012
62
);
--input
:
oklch
(
.35
.015
65
);
--ring
:
oklch
(
.65
.1
45
);
--success
:
oklch
(
.65
.15
145
);
--warning
:
oklch
(
.75
.12
75
);
--shadow-sm
:
0
1px
2px
oklch
(
0
0
0
/
.15
);
--shadow-md
:
0
4px
12px
oklch
(
0
0
0
/
.2
);
--shadow-lg
:
0
8px
24px
oklch
(
0
0
0
/
.25
)}
*
{
box-sizing
:
border-box
;
margin
:
0
;
padding
:
0
}
body
{
font-family
:
var
(
--font-sans
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
font-size
:
13px
;
line-height
:
1.5
;
-webkit-font-smoothing
:
antialiased
;
-moz-osx-font-smoothing
:
grayscale
;
width
:
380px
;
min-height
:
200px
;
overflow-x
:
hidden
}
@keyframes
fadeIn
{
0
%
{
opacity
:
0
;
transform
:
translateY
(
6px
)}
to
{
opacity
:
1
;
transform
:
translateY
(
0
)}}
@keyframes
slideUp
{
0
%
{
opacity
:
0
;
transform
:
translateY
(
12px
)}
to
{
opacity
:
1
;
transform
:
translateY
(
0
)}}
@keyframes
pulse
{
0
%,
to
{
opacity
:
1
}
50
%
{
opacity
:
.5
}}
@keyframes
spin
{
0
%
{
transform
:
rotate
(
0
)}
to
{
transform
:
rotate
(
360deg
)}}
@keyframes
glow
{
0
%,
to
{
box-shadow
:
0
0
4px
#3a97424
d
}
50
%
{
box-shadow
:
0
0
10px
#3a974280
}}
@keyframes
successPop
{
0
%
{
transform
:
scale
(
.9
);
opacity
:
0
}
50
%
{
transform
:
scale
(
1.02
)}
to
{
transform
:
scale
(
1
);
opacity
:
1
}}
.popup-container
{
display
:
flex
;
flex-direction
:
column
;
min-height
:
100%
;
animation
:
fadeIn
.2s
ease-out
}
.header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
14px
18px
;
background
:
var
(
--card
);
border-bottom
:
1px
solid
var
(
--border
);
-webkit-backdrop-filter
:
blur
(
10px
);
backdrop-filter
:
blur
(
10px
)}
.header-brand
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-weight
:
700
;
font-size
:
15px
;
color
:
var
(
--foreground
);
letter-spacing
:
-.01em
}
.header-brand-icon
{
font-size
:
20px
;
filter
:
drop-shadow
(
0
1px
2px
oklch
(
0
0
0
/
.1
))}
.header-status
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
11px
;
font-weight
:
500
;
color
:
var
(
--muted-foreground
)}
.status-dot
{
width
:
8px
;
height
:
8px
;
border-radius
:
50%
;
flex-shrink
:
0
}
.status-dot.status-connected
{
background
:
var
(
--success
);
animation
:
glow
2s
ease-in-out
infinite
}
.status-dot.status-offline
{
background
:
var
(
--destructive
)}
.status-text
{
font-size
:
11px
}
.content
{
padding
:
16px
18px
;
animation
:
fadeIn
.3s
ease-out
}
.note-form
{
display
:
flex
;
flex-direction
:
column
;
animation
:
slideUp
.25s
ease-out
}
.note-textarea
{
display
:
block
;
width
:
100%
;
min-height
:
100px
;
max-height
:
200px
;
resize
:
vertical
;
border
:
1.5px
solid
var
(
--border
);
border-radius
:
var
(
--radius
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
padding
:
12px
14px
;
font-size
:
14px
;
font-family
:
inherit
;
line-height
:
1.6
;
transition
:
border-color
.2s
ease
,
box-shadow
.2s
ease
}
.note-textarea
::placeholder
{
color
:
var
(
--muted-foreground
);
font-style
:
italic
}
.note-textarea
:focus
{
outline
:
none
;
border-color
:
var
(
--ring
);
box-shadow
:
0
0
0
3px
#7a462e1
a
}
.source-info
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-size
:
11px
;
color
:
var
(
--muted-foreground
);
padding
:
8px
12px
;
background
:
var
(
--muted
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
margin-top
:
10px
;
border
:
1px
solid
var
(
--border
)}
.label
{
display
:
block
;
font-size
:
11px
;
font-weight
:
600
;
margin-bottom
:
8px
;
color
:
var
(
--muted-foreground
);
text-transform
:
uppercase
;
letter-spacing
:
.05em
}
.tag-chips
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
6px
;
margin-top
:
6px
}
.tag-chip
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
5px
12px
;
border-radius
:
100px
;
font-size
:
12px
;
font-weight
:
500
;
cursor
:
pointer
;
transition
:
all
.2s
cubic-bezier
(
.4
,
0
,
.2
,
1
);
border
:
1.5px
solid
var
(
--border
);
background
:
var
(
--card
);
color
:
var
(
--muted-foreground
);
font-family
:
inherit
;
-webkit-user-select
:
none
;
user-select
:
none
}
.tag-chip
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--primary
);
background
:
#7a462e0
f
;
transform
:
translateY
(
-1px
);
box-shadow
:
var
(
--shadow-sm
)}
.tag-chip.selected
{
background
:
var
(
--primary
);
color
:
var
(
--primary-foreground
);
border-color
:
var
(
--primary
);
box-shadow
:
0
2px
6px
#7a462e33
}
.tag-chip.selected
:hover
{
filter
:
brightness
(
1.1
);
background
:
var
(
--primary
);
color
:
var
(
--primary-foreground
);
transform
:
translateY
(
-1px
)}
.tag-input-inline
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
5px
12px
;
border-radius
:
100px
;
font-size
:
12px
;
border
:
1.5px
dashed
var
(
--border
);
background
:
transparent
;
color
:
var
(
--muted-foreground
);
cursor
:
pointer
;
transition
:
all
.2s
ease
;
font-family
:
inherit
}
.tag-input-inline
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--primary
)}
.tag-input-field
{
border
:
none
;
outline
:
none
;
background
:
transparent
;
font-size
:
12px
;
font-family
:
inherit
;
color
:
var
(
--foreground
);
width
:
80px
}
.form-group
{
margin-bottom
:
14px
}
.select
{
display
:
block
;
width
:
100%
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
border
:
1.5px
solid
var
(
--border
);
background
:
var
(
--background
);
color
:
var
(
--foreground
);
padding
:
10px
36px
10px
14px
;
font-size
:
13px
;
font-family
:
inherit
;
cursor
:
pointer
;
appearance
:
none
;
background-image
:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")
;
background-repeat
:
no-repeat
;
background-position
:
right
12px
center
;
transition
:
border-color
.2s
ease
,
box-shadow
.2s
ease
}
.select
:focus
{
outline
:
none
;
border-color
:
var
(
--ring
);
box-shadow
:
0
0
0
3px
#7a462e1
a
}
.visibility-selector
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
6px
14px
;
border-radius
:
100px
;
font-size
:
12px
;
font-weight
:
500
;
border
:
1.5px
solid
var
(
--border
);
background
:
var
(
--card
);
color
:
var
(
--muted-foreground
);
cursor
:
pointer
;
transition
:
all
.2s
ease
;
font-family
:
inherit
}
.visibility-selector
:hover
{
border-color
:
var
(
--primary
);
color
:
var
(
--primary
)}
.btn
{
display
:
inline-flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
6px
;
white-space
:
nowrap
;
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
font-size
:
13px
;
font-weight
:
600
;
font-family
:
inherit
;
cursor
:
pointer
;
border
:
none
;
padding
:
9px
18px
;
transition
:
all
.2s
cubic-bezier
(
.4
,
0
,
.2
,
1
)}
.btn
:active
{
transform
:
scale
(
.96
)}
.btn-primary
{
background
:
linear-gradient
(
135deg
,
var
(
--primary
),
oklch
(
.5
.1
50
));
color
:
var
(
--primary-foreground
);
box-shadow
:
0
2px
8px
#7a462e40
}
.btn-primary
:hover
{
filter
:
brightness
(
1.08
);
box-shadow
:
0
4px
14px
#7a462e59
;
transform
:
translateY
(
-1px
)}
.btn-secondary
{
background
:
var
(
--secondary
);
color
:
var
(
--secondary-foreground
);
border
:
1.5px
solid
var
(
--border
)}
.btn-secondary
:hover
{
background
:
var
(
--muted
);
transform
:
translateY
(
-1px
)}
.btn-ghost
{
background
:
transparent
;
color
:
var
(
--muted-foreground
);
padding
:
6px
12px
}
.btn-ghost
:hover
{
background
:
var
(
--muted
);
color
:
var
(
--foreground
)}
.btn-sm
{
padding
:
6px
14px
;
font-size
:
12px
;
border-radius
:
calc
(
var
(
--radius
)
-
4px
)}
.btn
:disabled
{
opacity
:
.5
;
cursor
:
not-allowed
;
transform
:
none
!important
;
filter
:
none
!important
}
.action-bar
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
12px
18px
14px
;
border-top
:
1px
solid
var
(
--border
);
background
:
var
(
--card
)}
.action-bar-left
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
}
.action-bar-right
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
}
.badge
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
4px
10px
;
border-radius
:
100px
;
font-size
:
12px
;
font-weight
:
600
}
.badge-success
{
background
:
#3a97421
f
;
color
:
var
(
--success
);
border
:
1px
solid
oklch
(
.6
.15
145
/
.2
);
animation
:
successPop
.3s
ease-out
}
.error
{
padding
:
10px
14px
;
margin-top
:
10px
;
background
:
#b9464214
;
color
:
var
(
--destructive
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
font-size
:
12px
;
border
:
1px
solid
oklch
(
.55
.15
25
/
.15
);
animation
:
fadeIn
.2s
ease-out
}
.success
{
padding
:
10px
14px
;
margin-bottom
:
12px
;
background
:
#3a974214
;
color
:
var
(
--success
);
border-radius
:
calc
(
var
(
--radius
)
-
2px
);
font-size
:
12px
;
border
:
1px
solid
oklch
(
.6
.15
145
/
.15
)}
.spinner
{
display
:
inline-block
;
width
:
14px
;
height
:
14px
;
border
:
2px
solid
var
(
--border
);
border-top-color
:
var
(
--primary
);
border-radius
:
50%
;
animation
:
spin
.6s
linear
infinite
;
vertical-align
:
middle
}
::-webkit-scrollbar
{
width
:
5px
}
::-webkit-scrollbar-track
{
background
:
transparent
}
::-webkit-scrollbar-thumb
{
background
:
var
(
--border
);
border-radius
:
100px
}
::-webkit-scrollbar-thumb:hover
{
background
:
var
(
--muted-foreground
)}
*,*
:before
,*
:after
{
transition-property
:
background-color
,
color
,
border-color
,
box-shadow
,
opacity
;
transition-duration
:
.2s
;
transition-timing-function
:
ease
}
.spinner
,
.spinner
*,[
class
*=
animate-
]
{
transition
:
none
!important
}
extension/dist/manifest.json
View file @
25b29d27
{
"manifest_version"
:
3
,
"name"
:
"C
uCu
Note"
,
"version"
:
"1.
0
.0"
,
"description"
:
"
Quick note-taking extension - Highlight text and save to CuCu Note
"
,
"name"
:
"C
anifa
Note"
,
"version"
:
"1.
1
.0"
,
"description"
:
"
Ghi chú nhanh từ web — Bôi đen, nhấn Space, lưu tự động
"
,
"permissions"
:
[
"activeTab"
,
"storage"
,
...
...
@@ -20,7 +20,7 @@
"content_scripts"
:
[
{
"js"
:
[
"assets/content-script.ts-
BWL85FVS
.js"
"assets/content-script.ts-
CTdB63jU
.js"
],
"matches"
:
[
"<all_urls>"
...
...
@@ -48,7 +48,7 @@
"<all_urls>"
],
"resources"
:
[
"assets/content-script.ts-
BWL85FVS
.js"
"assets/content-script.ts-
CTdB63jU
.js"
],
"use_dynamic_url"
:
false
}
...
...
extension/dist/service-worker-loader.js
View file @
25b29d27
import
'./assets/service-worker.ts-
DvMHIkFe
.js'
;
import
'./assets/service-worker.ts-
C6gHU6BA
.js'
;
extension/dist/src/popup/popup.html
View file @
25b29d27
<!DOCTYPE html>
<html
lang=
"en"
>
<html
lang=
"en"
class=
"dark"
>
<head>
<meta
charset=
"UTF-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<title>
CuCu Note
</title>
<link
rel=
"preconnect"
href=
"https://fonts.googleapis.com"
>
<link
rel=
"preconnect"
href=
"https://fonts.gstatic.com"
crossorigin
>
<link
href=
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel=
"stylesheet"
>
<title>
Canifa Note
</title>
<style>
body
{
margin
:
0
;
padding
:
0
;
width
:
380px
;
min-height
:
42
0px
;
min-height
:
38
0px
;
max-height
:
560px
;
overflow-y
:
auto
;
}
</style>
<script
type=
"module"
crossorigin
src=
"/assets/popup-
DmXuB8QF
.js"
></script>
<link
rel=
"modulepreload"
crossorigin
href=
"/assets/api-client-
Y5oDKlCr
.js"
>
<link
rel=
"stylesheet"
crossorigin
href=
"/assets/popup-
dYtSf2jU
.css"
>
<script
type=
"module"
crossorigin
src=
"/assets/popup-
CSzsyzxK
.js"
></script>
<link
rel=
"modulepreload"
crossorigin
href=
"/assets/api-client-
CzjXLdoC
.js"
>
<link
rel=
"stylesheet"
crossorigin
href=
"/assets/popup-
C6DDov6Y
.css"
>
</head>
<body>
<div
id=
"popup-root"
></div>
...
...
extension/manifest.json
View file @
25b29d27
{
"manifest_version"
:
3
,
"name"
:
"C
uCu
Note"
,
"version"
:
"1.
0
.0"
,
"description"
:
"
Quick note-taking extension - Highlight text and save to CuCu Note
"
,
"name"
:
"C
anifa
Note"
,
"version"
:
"1.
1
.0"
,
"description"
:
"
Ghi chú nhanh từ web — Bôi đen, nhấn Space, lưu tự động
"
,
"permissions"
:
[
"activeTab"
,
"storage"
,
...
...
extension/src/background/service-worker.ts
View file @
25b29d27
/**
* Background Service Worker
*
Nhiệm vụ:
* 1. Handle SYNC_AUTH
từ content script (auto-sync Clerk token)
* 2. Handle SAVE_NOTE
từ content script (gọi API save memo)
* 3. Handle REFRESH_TOKEN
từ popup (refresh Clerk JWT trước khi gọi API)
* Background Service Worker
— Canifa Note
*
* 1. Handle SYNC_AUTH
from content script (auto-sync Memos token)
* 2. Handle SAVE_NOTE
from content script (call API save memo)
* 3. Handle REFRESH_TOKEN
from popup
*/
import
{
createMemo
}
from
'../shared/api-client'
;
/**
* Grab fresh Clerk token from ANY OpenNotion tab
* Scans all open tabs, finds OpenNotion, injects script to get token
* Grab fresh Memos token from any Canifa Note tab
*/
async
function
grabFreshToken
():
Promise
<
string
|
null
>
{
try
{
...
...
@@ -19,9 +18,9 @@ async function grabFreshToken(): Promise<string | null> {
const
url
=
t
.
url
||
''
;
return
(
url
.
includes
(
'172.16.2.210'
)
||
url
.
includes
(
'localhost:
3001'
)
||
url
.
includes
(
'
opennotion'
)
||
url
.
includes
(
'c
ucunote'
)
url
.
includes
(
'localhost:
5230'
)
||
url
.
includes
(
'
cucunote'
)
||
url
.
includes
(
'c
anifa'
)
);
});
...
...
@@ -31,28 +30,20 @@ async function grabFreshToken(): Promise<string | null> {
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
world
:
'MAIN'
,
func
:
async
()
=>
{
func
:
()
=>
{
try
{
// 1.
Try Memos cookie
// 1.
Memos access-token cookie
const
match
=
document
.
cookie
.
match
(
/
(?:
^|;
)
memos
\.
access-token=
([^
;
]
+
)
/
);
if
(
match
&&
match
[
1
])
return
match
[
1
];
// 2.
Try localStorage
// 2.
localStorage
for
(
let
i
=
0
;
i
<
localStorage
.
length
;
i
++
)
{
const
key
=
localStorage
.
key
(
i
);
if
(
key
&&
(
key
.
includes
(
'
token'
)
||
key
.
includes
(
'session'
)))
{
if
(
key
&&
(
key
.
includes
(
'
access_token'
)
||
key
.
includes
(
'token'
)))
{
const
val
=
localStorage
.
getItem
(
key
);
if
(
val
&&
typeof
val
===
'string'
&&
val
.
length
>
50
&&
!
val
.
startsWith
(
'{'
))
{
return
val
;
if
(
val
&&
val
.
length
>
20
&&
!
val
.
startsWith
(
'{'
))
return
val
;
}
}
}
// 3. Try Clerk
const
clerk
=
(
window
as
any
).
Clerk
;
if
(
clerk
?.
session
?.
getToken
)
{
return
await
clerk
.
session
.
getToken
();
}
}
catch
{
/* ignore */
}
return
null
;
},
...
...
@@ -60,14 +51,14 @@ async function grabFreshToken(): Promise<string | null> {
const
token
=
results
[
0
]?.
result
;
if
(
token
)
{
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
token
,
clerkTokenSyncedAt
:
Date
.
now
(),
memosAccessToken
:
token
,
tokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[C
uCu BG] ✅ Fresh token from tab'
,
tab
.
id
,
`(
${
token
.
length
}
chars)`
);
console
.
log
(
'[C
anifa Note] ✅ Token synced from tab'
,
tab
.
id
);
return
token
;
}
}
catch
{
// T
his tab didn't work, try next
// T
ry next tab
}
}
}
catch
{
...
...
@@ -79,62 +70,49 @@ async function grabFreshToken(): Promise<string | null> {
chrome
.
runtime
.
onMessage
.
addListener
((
message
,
_sender
,
sendResponse
)
=>
{
// ============ AUTO-SYNC AUTH TOKEN ============
if
(
message
.
type
===
'SYNC_AUTH'
)
{
const
{
clerkSessionToken
}
=
message
.
data
||
{};
if
(
clerkSessionToken
)
{
const
token
=
message
.
data
?.
memosAccessToken
||
message
.
data
?.
clerkSessionToken
;
if
(
token
)
{
chrome
.
storage
.
local
.
set
({
clerkSessionToken
,
clerkTokenSyncedAt
:
Date
.
now
(),
memosAccessToken
:
token
,
tokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[C
uCu BG] ✅ Clerk token auto-synced from content script'
);
console
.
log
(
'[C
anifa Note] ✅ Token auto-synced from content script'
);
}
sendResponse
({
success
:
true
});
return
false
;
// synchronous response
return
false
;
}
// ============ REFRESH TOKEN
(popup requests fresh token) ============
// ============ REFRESH TOKEN
============
if
(
message
.
type
===
'REFRESH_TOKEN'
)
{
// Scan ALL OpenNotion tabs to get a fresh Clerk JWT
grabFreshToken
()
.
then
((
token
)
=>
{
if
(
token
)
{
sendResponse
({
success
:
true
,
token
});
}
else
{
sendResponse
({
success
:
false
,
reason
:
'no_
opennotion_tab'
});
sendResponse
({
success
:
false
,
reason
:
'no_
canifa_note_tab'
});
}
})
.
catch
((
err
)
=>
{
console
.
log
(
'[CuCu BG] ❌ Token refresh failed:'
,
err
?.
message
);
sendResponse
({
success
:
false
,
reason
:
err
?.
message
});
});
return
true
;
// keep channel open for async
return
true
;
}
// ============ SHOW NOTE FORM ============
if
(
message
.
type
===
'SHOW_NOTE_FORM'
)
{
// Mở popup với note form
chrome
.
action
.
openPopup
();
// Lưu data vào storage để popup có thể lấy
chrome
.
storage
.
local
.
set
({
pendingNote
:
message
.
data
,
});
chrome
.
storage
.
local
.
set
({
pendingNote
:
message
.
data
});
sendResponse
({
success
:
true
});
}
// ============ SAVE NOTE ============
else
if
(
message
.
type
===
'SAVE_NOTE'
)
{
// Auto save note ngay lập tức
const
{
text
,
url
,
title
}
=
message
.
data
;
// Parse tags từ URL
const
domain
=
new
URL
(
url
).
hostname
.
replace
(
'www.'
,
''
);
const
tagList
=
[
domain
,
'web-highlight'
];
// Add source info vào content
const
contentWithSource
=
`
${
text
}
\n\n---\nSource: [
${
title
}
](
${
url
}
)`
;
// Gọi API để save (có gửi kèm Clerk token trong Authorization header)
const
memoData
=
{
content
:
contentWithSource
,
tags
:
tagList
,
...
...
@@ -149,8 +127,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse
({
success
:
false
,
error
:
error
.
message
});
});
return
true
;
// Keep channel open for async
return
true
;
}
return
true
;
// Keep channel open for async response
return
true
;
});
extension/src/components/NoteForm.tsx
View file @
25b29d27
/**
* NoteForm Component
* NoteForm Component
— shadcn/ui styled
*
*
The main note-taking form. Accepts a getToken prop from Clerk
*
to get fresh JWTs for API calls. Does NOT close the popup after save.
*
Clean, minimal note form. Always visible.
*
Uses Memos access token for API calls.
*/
import
{
useState
}
from
'react'
;
import
{
createMemoWithToken
}
from
'../shared/api-client'
;
import
{
TagSelector
}
from
'./TagSelector'
;
import
{
WorkspaceSelector
}
from
'./WorkspaceSelector'
;
interface
NoteFormProps
{
initialText
?:
string
;
...
...
@@ -29,7 +28,6 @@ export function NoteForm({
}:
NoteFormProps
)
{
const
[
text
,
setText
]
=
useState
(
initialText
);
const
[
tags
,
setTags
]
=
useState
<
string
[]
>
([]);
const
[
workspace
,
setWorkspace
]
=
useState
(
''
);
const
[
visibility
,
setVisibility
]
=
useState
<
'PRIVATE'
|
'PUBLIC'
>
(
'PRIVATE'
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
...
...
@@ -37,7 +35,7 @@ export function NoteForm({
const
handleSave
=
async
()
=>
{
if
(
!
text
.
trim
())
{
setError
(
'
Note cannot be empty'
);
setError
(
'
Chưa nhập nội dung ghi chú'
);
return
;
}
...
...
@@ -46,42 +44,33 @@ export function NoteForm({
setSaved
(
false
);
try
{
// Get FRESH token from Clerk
const
token
=
await
getToken
();
if
(
!
token
)
{
setError
(
'
Not authenticated. Please sign in again.'
);
setError
(
'
Chưa đồng bộ. Mở Canifa Note trên trình duyệt và đăng nhập.'
);
setLoading
(
false
);
return
;
}
// Build content with source info
let
content
=
text
;
if
(
initialUrl
&&
initialUrl
!==
'about:blank'
)
{
content
+=
`\n\n---\nSource: [
${
initialTitle
||
initialUrl
}
](
${
initialUrl
}
)`
;
}
// Call API with fresh token
await
createMemoWithToken
(
token
,
{
content
,
tags
,
visibility
,
});
// Success! Show message but KEEP popup open
setLoading
(
false
);
setSaved
(
true
);
// Clear form for next note
setText
(
''
);
setTags
([]);
setError
(
null
);
// Auto-hide success after 3s
setTimeout
(()
=>
setSaved
(
false
),
3000
);
if
(
onSave
)
onSave
();
}
catch
(
err
)
{
setError
(
err
instanceof
Error
?
err
.
message
:
'
Failed to save note'
);
setError
(
err
instanceof
Error
?
err
.
message
:
'
Lưu thất bại'
);
setLoading
(
false
);
}
};
...
...
@@ -89,54 +78,41 @@ export function NoteForm({
return
(
<
div
className=
"note-form"
>
<
div
className=
"content"
>
{
/* Success
Banner */
}
{
/* Success
*/
}
{
saved
&&
(
<
div
className=
"badge badge-success"
style=
{
{
textAlign
:
'center'
,
padding
:
'10px'
,
fontSize
:
'14px'
,
marginBottom
:
'8px'
,
animation
:
'fadeIn 0.3s ease'
}
}
>
✅ Saved to CuCu Note!
<
div
className=
"success"
style=
{
{
textAlign
:
'center'
,
animation
:
'fadeIn 0.2s ease'
}
}
>
✓ Đã lưu vào Canifa Note
</
div
>
)
}
{
/* Text
Area */
}
{
/* Text
area */
}
<
textarea
className=
"note-textarea"
placeholder=
"
Any thoughts..."
placeholder=
"
Ghi chú..."
value=
{
text
}
onChange=
{
(
e
)
=>
setText
(
e
.
target
.
value
)
}
rows=
{
4
}
autoFocus
/>
{
/* Source
Info */
}
{
/* Source
*/
}
{
initialUrl
&&
initialUrl
!==
'about:blank'
&&
(
<
div
className=
"source-info"
style=
{
{
fontSize
:
'12px'
,
color
:
'var(--text-secondary)'
,
padding
:
'6px 10px'
,
backgroundColor
:
'var(--surface)'
,
borderRadius
:
'8px'
,
marginTop
:
'6px'
,
overflow
:
'hidden'
,
textOverflow
:
'ellipsis'
,
whiteSpace
:
'nowrap'
,
}
}
>
🔗
{
initialTitle
||
initialUrl
}
<
div
className=
"source-info"
>
<
svg
className=
"source-icon"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
<
path
d=
"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
/>
<
path
d=
"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
/>
</
svg
>
<
span
style=
{
{
overflow
:
'hidden'
,
textOverflow
:
'ellipsis'
,
whiteSpace
:
'nowrap'
}
}
>
{
initialTitle
||
initialUrl
}
</
span
>
</
div
>
)
}
{
/* Tag
Selector */
}
<
div
style=
{
{
marginTop
:
'1
2px'
}
}
>
{
/* Tag
s */
}
<
div
style=
{
{
marginTop
:
'1
0px'
}
}
>
<
TagSelector
selectedTags=
{
tags
}
onChange=
{
setTags
}
/>
</
div
>
{
/* Workspace Selector */
}
<
WorkspaceSelector
value=
{
workspace
}
onChange=
{
setWorkspace
}
/>
{
/* Error */
}
{
error
&&
<
div
className=
"error"
>
{
error
}
</
div
>
}
</
div
>
...
...
@@ -146,12 +122,21 @@ export function NoteForm({
<
div
className=
"action-bar-left"
>
<
button
className=
"visibility-selector"
onClick=
{
()
=>
setVisibility
(
visibility
===
'PRIVATE'
?
'PUBLIC'
:
'PRIVATE'
)
}
title=
{
`Click to toggle: ${visibility}`
}
onClick=
{
()
=>
setVisibility
(
visibility
===
'PRIVATE'
?
'PUBLIC'
:
'PRIVATE'
)
}
title=
{
`Toggle: ${visibility}`
}
>
{
visibility
===
'PRIVATE'
?
'🔒'
:
'🌐'
}{
' '
}
{
visibility
===
'PRIVATE'
?
(
<
svg
width=
"12"
height=
"12"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
<
rect
x=
"3"
y=
"11"
width=
"18"
height=
"11"
rx=
"2"
ry=
"2"
/>
<
path
d=
"M7 11V7a5 5 0 0 1 10 0v4"
/>
</
svg
>
)
:
(
<
svg
width=
"12"
height=
"12"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
<
circle
cx=
"12"
cy=
"12"
r=
"10"
/>
<
line
x1=
"2"
y1=
"12"
x2=
"22"
y2=
"12"
/>
<
path
d=
"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
</
svg
>
)
}
{
visibility
===
'PRIVATE'
?
'Private'
:
'Public'
}
</
button
>
</
div
>
...
...
@@ -169,10 +154,10 @@ export function NoteForm({
disabled=
{
loading
||
!
text
.
trim
()
}
>
{
saved
?
(
'
✅ Saved!'
'
✓ Saved'
)
:
loading
?
(
<>
<
span
className=
"spinner"
/>
Saving...
<
span
className=
"spinner"
style=
{
{
width
:
12
,
height
:
12
}
}
/>
Saving...
</>
)
:
(
'Save'
...
...
extension/src/components/Settings.tsx
View file @
25b29d27
/**
* Settings Component
* Server URL config, auth sync, connection test
* Settings Component — shadcn/ui styled
*/
import
{
useState
,
useEffect
}
from
'react'
;
...
...
@@ -8,7 +7,6 @@ import {
getApiBaseUrl
,
setApiBaseUrl
,
getAuthStatus
,
syncClerkTokenFromPage
,
testConnection
,
}
from
'../shared/api-client'
;
...
...
@@ -19,7 +17,6 @@ export function Settings() {
tokenLength
:
0
,
});
const
[
testResult
,
setTestResult
]
=
useState
<
{
ok
:
boolean
;
message
:
string
}
|
null
>
(
null
);
const
[
syncing
,
setSyncing
]
=
useState
(
false
);
const
[
testing
,
setTesting
]
=
useState
(
false
);
const
[
saved
,
setSaved
]
=
useState
(
false
);
...
...
@@ -35,7 +32,7 @@ export function Settings() {
};
const
handleSaveUrl
=
async
()
=>
{
const
cleaned
=
serverUrl
.
trim
().
replace
(
/
\/
+$/
,
''
);
// remove trailing slashes
const
cleaned
=
serverUrl
.
trim
().
replace
(
/
\/
+$/
,
''
);
await
setApiBaseUrl
(
cleaned
);
setServerUrl
(
cleaned
);
setSaved
(
true
);
...
...
@@ -43,21 +40,6 @@ export function Settings() {
setTimeout
(()
=>
setSaved
(
false
),
2000
);
};
const
handleSyncAuth
=
async
()
=>
{
setSyncing
(
true
);
try
{
const
token
=
await
syncClerkTokenFromPage
();
if
(
token
)
{
setAuthStatus
({
isConnected
:
true
,
tokenLength
:
token
.
length
});
}
else
{
setAuthStatus
({
isConnected
:
false
,
tokenLength
:
0
});
}
}
catch
{
setAuthStatus
({
isConnected
:
false
,
tokenLength
:
0
});
}
setSyncing
(
false
);
};
const
handleTestConnection
=
async
()
=>
{
setTesting
(
true
);
setTestResult
(
null
);
...
...
@@ -68,10 +50,9 @@ export function Settings() {
return
(
<
div
className=
"content"
>
{
/* Server
URL */
}
{
/* Server
*/
}
<
div
className=
"settings-section"
>
<
div
className=
"settings-title"
>
🌐 Server
</
div
>
<
div
className=
"settings-title"
>
Server
</
div
>
<
div
className=
"form-group"
>
<
label
className=
"label"
>
API URL
</
label
>
<
div
className=
"settings-input-group"
>
...
...
@@ -84,18 +65,16 @@ export function Settings() {
setSaved
(
false
);
setTestResult
(
null
);
}
}
placeholder=
"http
s://your-domain.com"
placeholder=
"http
://172.16.2.210:5230"
/>
<
button
className=
"btn btn-primary btn-sm"
onClick=
{
handleSaveUrl
}
>
{
saved
?
'✓'
:
'Save'
}
</
button
>
</
div
>
<
div
className=
"label-hint"
>
Backend server URL (without /api/v1)
</
div
>
<
div
className=
"label-hint"
>
Backend server URL (without /api/v1)
</
div
>
</
div
>
<
div
style=
{
{
marginTop
:
'
8px'
}
}
>
<
div
style=
{
{
marginTop
:
'
6px'
}
}
>
<
button
className=
"btn btn-secondary btn-sm btn-full"
onClick=
{
handleTestConnection
}
...
...
@@ -103,78 +82,45 @@ export function Settings() {
>
{
testing
?
(
<>
<
span
className=
"spinner"
/>
Testing...
<
span
className=
"spinner"
style=
{
{
width
:
12
,
height
:
12
}
}
/>
Testing...
</>
)
:
(
'
🔌 Test Connection'
'
Test Connection'
)
}
</
button
>
</
div
>
{
testResult
&&
(
<
div
className=
{
testResult
.
ok
?
'success'
:
'error'
}
style=
{
{
marginTop
:
'8px'
}
}
>
<
div
className=
{
testResult
.
ok
?
'success'
:
'error'
}
style=
{
{
marginTop
:
'6px'
}
}
>
{
testResult
.
message
}
</
div
>
)
}
</
div
>
{
/* Auth
*/
}
{
/* Auth
Status */
}
<
div
className=
"settings-section"
>
<
div
className=
"settings-title"
>
🔑 Authentication
</
div
>
<
div
className=
"settings-title"
>
Authentication
</
div
>
<
div
className=
"settings-item"
>
<
div
>
<
div
className=
"settings-item-label"
>
Clerk Session
</
div
>
<
div
className=
"settings-item-desc"
>
Sync auth from your OpenNotion app
</
div
>
<
div
className=
"settings-item-label"
>
Memos Access Token
</
div
>
<
div
className=
"settings-item-desc"
>
Auto-synced from Canifa Note tab
</
div
>
</
div
>
<
span
className=
{
`badge ${authStatus.isConnected ? 'badge-success' : 'badge-error'}`
}
>
<
span
className=
{
`status-dot ${authStatus.isConnected ? 'connected' : 'disconnected'}`
}
/>
<
span
className=
{
`status-dot ${authStatus.isConnected ? 'connected' : 'disconnected'}`
}
/>
{
authStatus
.
isConnected
?
'Active'
:
'Inactive'
}
</
span
>
</
div
>
<
div
style=
{
{
marginTop
:
'8px'
}
}
>
<
button
className=
"btn btn-secondary btn-sm btn-full"
onClick=
{
handleSyncAuth
}
disabled=
{
syncing
}
>
{
syncing
?
(
<>
<
span
className=
"spinner"
/>
Syncing...
</>
)
:
(
'🔄 Sync Auth from Current Page'
)
}
</
button
>
<
div
className=
"label-hint"
style=
{
{
marginTop
:
'6px'
}
}
>
Open your OpenNotion app first, then click sync
</
div
>
</
div
>
</
div
>
{
/* About */
}
<
div
className=
"settings-section"
>
<
div
className=
"settings-title"
>
ℹ️ About
</
div
>
<
div
style=
{
{
fontSize
:
'12px'
,
color
:
'var(--muted-foreground)'
,
lineHeight
:
'1.6'
,
}
}
>
<
strong
>
CuCu Note
</
strong
>
v1.0.0
<
div
className=
"settings-title"
>
About
</
div
>
<
div
style=
{
{
fontSize
:
'12px'
,
color
:
'var(--muted-foreground)'
,
lineHeight
:
'1.6'
}
}
>
<
strong
>
Canifa Note
</
strong
>
v1.0.0
<
br
/>
Quick note-taking from any web page.
Ghi chú nhanh từ bất kỳ trang web nào.
<
br
/>
Highlight text → Press Space → Auto save!
Bôi đen → Nhấn Space → Lưu tự động
</
div
>
</
div
>
</
div
>
...
...
extension/src/components/TagSelector.tsx
View file @
25b29d27
/**
* Tag Selector Component
* Chip-based tag selector with server-fetched tags + inline new tag input
* Tag Selector — shadcn/ui badge chips
*/
import
{
useState
,
useEffect
,
useRef
}
from
'react'
;
...
...
@@ -35,11 +34,10 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
fetchTags
(),
getRecentTags
(),
]);
// Merge: recent first, then server tags
const
merged
=
[...
new
Set
([...
recentTags
,
...
serverTags
])];
setAvailableTags
(
merged
);
}
catch
{
// Fallback
to empty
// Fallback
}
setLoading
(
false
);
};
...
...
@@ -49,7 +47,6 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
onChange
(
selectedTags
.
filter
((
t
)
=>
t
!==
tag
));
}
else
{
onChange
([...
selectedTags
,
tag
]);
// Save to recent
saveRecentTags
([
tag
,
...
selectedTags
]);
}
};
...
...
@@ -77,8 +74,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
}
};
// Show max 12 tags to keep UI compact
const
displayTags
=
availableTags
.
slice
(
0
,
12
);
const
displayTags
=
availableTags
.
slice
(
0
,
10
);
return
(
<
div
className=
"form-group"
>
...
...
@@ -86,7 +82,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
<
div
className=
"tag-chips"
>
{
loading
?
(
<
span
style=
{
{
fontSize
:
'11px'
,
color
:
'var(--muted-foreground)'
}
}
>
Loading
tags...
Loading
...
</
span
>
)
:
(
<>
...
...
@@ -113,7 +109,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
onChange=
{
(
e
)
=>
setNewTag
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
onBlur=
{
addNewTag
}
placeholder=
"
new tag"
placeholder=
"
tag"
/>
</
div
>
)
:
(
...
...
@@ -128,11 +124,6 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
</>
)
}
</
div
>
{
selectedTags
.
length
>
0
&&
(
<
div
className=
"label-hint"
>
{
selectedTags
.
length
}
tag
{
selectedTags
.
length
>
1
?
's'
:
''
}
selected
</
div
>
)
}
</
div
>
);
}
extension/src/content/content-script.ts
View file @
25b29d27
This diff is collapsed.
Click to expand it.
extension/src/popup/popup.css
View file @
25b29d27
This diff is collapsed.
Click to expand it.
extension/src/popup/popup.html
View file @
25b29d27
<!DOCTYPE html>
<html
lang=
"en"
>
<html
lang=
"en"
class=
"dark"
>
<head>
<meta
charset=
"UTF-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<title>
CuCu Note
</title>
<link
rel=
"preconnect"
href=
"https://fonts.googleapis.com"
>
<link
rel=
"preconnect"
href=
"https://fonts.gstatic.com"
crossorigin
>
<link
href=
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel=
"stylesheet"
>
<title>
Canifa Note
</title>
<link
rel=
"stylesheet"
href=
"./popup.css"
>
<style>
body
{
margin
:
0
;
padding
:
0
;
width
:
380px
;
min-height
:
420px
;
min-height
:
380px
;
max-height
:
560px
;
overflow-y
:
auto
;
}
...
...
extension/src/popup/popup.tsx
View file @
25b29d27
This diff is collapsed.
Click to expand it.
extension/src/shared/api-client.ts
View file @
25b29d27
/**
* API Client
- Gọi backend CuCu Note
*
Configurable server URL + tag/workspace fetching
* API Client
— Canifa Note (Memos backend)
*
Uses Memos access-token for auth
*/
// Default API base URL
— empty means user must configure in Settings
// Default API base URL
const
DEFAULT_API_BASE_URL
=
'http://172.16.2.210:5230'
;
// ========== Config ==========
...
...
@@ -20,7 +20,6 @@ export async function getApiBaseUrl(): Promise<string> {
}
export
async
function
setApiBaseUrl
(
url
:
string
):
Promise
<
void
>
{
// Basic URL validation
const
cleaned
=
url
.
trim
().
replace
(
/
\/
+$/
,
''
);
if
(
cleaned
&&
!
cleaned
.
startsWith
(
'http://'
)
&&
!
cleaned
.
startsWith
(
'https://'
))
{
throw
new
Error
(
'URL must start with http:// or https://'
);
...
...
@@ -31,7 +30,9 @@ export async function setApiBaseUrl(url: string): Promise<void> {
// ========== Auth ==========
async
function
getAuthToken
():
Promise
<
string
|
null
>
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'clerkSessionToken'
,
'authToken'
]);
const
result
=
await
chrome
.
storage
.
local
.
get
([
'memosAccessToken'
,
'clerkSessionToken'
,
'authToken'
]);
// Priority: Memos token > Clerk token > manual token
if
(
result
.
memosAccessToken
)
return
result
.
memosAccessToken
;
if
(
result
.
clerkSessionToken
)
return
result
.
clerkSessionToken
;
return
result
.
authToken
||
null
;
}
...
...
@@ -48,79 +49,9 @@ export async function getAuthStatus(): Promise<{ isConnected: boolean; tokenLeng
};
}
/**
* Sync Clerk token from the currently active page (must be the frontend app)
*/
export
async
function
syncClerkTokenFromPage
():
Promise
<
string
|
null
>
{
try
{
const
[
tab
]
=
await
chrome
.
tabs
.
query
({
active
:
true
,
currentWindow
:
true
});
if
(
!
tab
.
id
)
return
null
;
const
results
=
await
chrome
.
scripting
.
executeScript
({
target
:
{
tabId
:
tab
.
id
},
func
:
async
()
=>
{
if
(
typeof
window
!==
'undefined'
&&
(
window
as
any
).
Clerk
?.
session
?.
getToken
)
{
try
{
const
token
=
await
(
window
as
any
).
Clerk
.
session
.
getToken
();
return
token
||
null
;
}
catch
{
return
null
;
}
}
return
null
;
},
});
const
token
=
results
[
0
]?.
result
||
null
;
if
(
token
)
{
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
token
});
}
return
token
;
}
catch
{
return
null
;
}
}
// ========== API Helpers ==========
// Token is stale if synced more than 45s ago (Clerk JWT expires ~60s)
const
TOKEN_MAX_AGE_MS
=
45
_000
;
async
function
tryRefreshToken
():
Promise
<
void
>
{
try
{
const
result
=
await
chrome
.
storage
.
local
.
get
([
'clerkTokenSyncedAt'
]);
const
syncedAt
=
result
.
clerkTokenSyncedAt
||
0
;
const
age
=
Date
.
now
()
-
syncedAt
;
// Only refresh if token is stale
if
(
age
>
TOKEN_MAX_AGE_MS
)
{
console
.
log
(
'[CuCu API] Token stale, refreshing...'
,
{
age
:
Math
.
round
(
age
/
1000
)
+
's'
});
const
response
=
await
new
Promise
<
any
>
((
resolve
)
=>
{
chrome
.
runtime
.
sendMessage
({
type
:
'REFRESH_TOKEN'
},
(
res
)
=>
{
resolve
(
res
);
});
});
// Background returned fresh token — save it
if
(
response
?.
success
&&
response
?.
token
)
{
await
chrome
.
storage
.
local
.
set
({
clerkSessionToken
:
response
.
token
,
clerkTokenSyncedAt
:
Date
.
now
(),
});
console
.
log
(
'[CuCu API] ✅ Token refreshed'
,
response
.
token
.
length
,
'chars'
);
}
else
{
console
.
log
(
'[CuCu API] ⚠️ Token refresh failed:'
,
response
?.
reason
||
'unknown'
);
}
}
}
catch
(
err
:
any
)
{
console
.
log
(
'[CuCu API] ❌ Token refresh error:'
,
err
?.
message
);
}
}
async
function
apiHeaders
():
Promise
<
Record
<
string
,
string
>>
{
// Try to refresh token if stale
await
tryRefreshToken
();
const
token
=
await
getAuthToken
();
const
headers
:
Record
<
string
,
string
>
=
{
'Content-Type'
:
'application/json'
};
if
(
token
)
{
...
...
@@ -159,7 +90,7 @@ export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse>
});
if
(
!
response
.
ok
)
{
await
response
.
text
();
// consume body
await
response
.
text
();
throw
new
Error
(
`Failed to create memo (
${
response
.
status
}
)`
);
}
...
...
@@ -168,8 +99,7 @@ export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse>
}
/**
* Create a memo using a direct token (from Clerk getToken())
* Bypasses storage-based token reading — always uses fresh JWT
* Create a memo using a direct token
*/
export
async
function
createMemoWithToken
(
token
:
string
,
data
:
CreateMemoRequest
):
Promise
<
MemoResponse
>
{
const
apiBase
=
await
getApiBase
();
...
...
@@ -207,16 +137,14 @@ export async function fetchTags(): Promise<string[]> {
const
memos
:
any
[]
=
await
response
.
json
();
// Extract unique tags from all memos
const
tagSet
=
new
Set
<
string
>
();
for
(
const
memo
of
memos
)
{
if
(
Array
.
isArray
(
memo
.
tags
))
{
memo
.
tags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
));
}
// Also parse #tag from content
const
hashTags
=
(
memo
.
content
||
''
).
match
(
/#
([
a-zA-Z0-9_
\u
00C0-
\u
024F
\u
1E00-
\u
1EFF
]
+
)
/g
);
if
(
hashTags
)
{
hashTags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
.
slice
(
1
)));
// remove #
hashTags
.
forEach
((
t
:
string
)
=>
tagSet
.
add
(
t
.
slice
(
1
)));
}
}
...
...
@@ -226,7 +154,7 @@ export async function fetchTags(): Promise<string[]> {
}
}
// ========== Workspaces
(Shortcuts) ==========
// ========== Workspaces
==========
export
interface
WorkspaceItem
{
id
:
number
;
...
...
@@ -243,7 +171,6 @@ export async function fetchWorkspaces(): Promise<WorkspaceItem[]> {
if
(
!
response
.
ok
)
return
[];
const
data
=
await
response
.
json
();
// API may return array directly or { shortcuts: [...] }
const
shortcuts
=
Array
.
isArray
(
data
)
?
data
:
data
.
shortcuts
||
[];
return
shortcuts
.
map
((
s
:
any
)
=>
({
id
:
s
.
id
,
...
...
@@ -286,7 +213,6 @@ export async function getRecentTags(): Promise<string[]> {
}
export
async
function
saveRecentTags
(
tags
:
string
[]):
Promise
<
void
>
{
// Keep only last 20 unique tags
const
unique
=
[...
new
Set
(
tags
)].
slice
(
0
,
20
);
await
chrome
.
storage
.
local
.
set
({
recentTags
:
unique
});
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment