docs: Add AGPL-3.0 license and update README for open source release
This commit is contained in:
17
LICENSE
Normal file
17
LICENSE
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2024-2026 Ciphera
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Analytics Frontend
|
# Analytics Frontend
|
||||||
|
|
||||||
[](#)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
|
|
||||||
Analytics Frontend is the dashboard interface for Ciphera Analytics. It provides a simple, intuitive interface for managing sites and viewing analytics data.
|
Analytics Frontend is the dashboard interface for Ciphera Analytics. It provides a simple, intuitive interface for managing sites and viewing analytics data.
|
||||||
@@ -73,4 +73,4 @@ The frontend follows the Ciphera design language:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Proprietary - All rights reserved
|
AGPL-3.0
|
||||||
|
|||||||
114
components/dashboard/Countries.tsx
Normal file
114
components/dashboard/Countries.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { formatNumber } from '@/lib/utils/format'
|
||||||
|
import * as Flags from 'country-flag-icons/react/3x2'
|
||||||
|
import WorldMap from './WorldMap'
|
||||||
|
|
||||||
|
interface LocationProps {
|
||||||
|
countries: Array<{ country: string; pageviews: number }>
|
||||||
|
cities: Array<{ city: string; country: string; pageviews: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'countries' | 'cities'
|
||||||
|
|
||||||
|
export default function Locations({ countries, cities }: LocationProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('countries')
|
||||||
|
|
||||||
|
const getFlagComponent = (countryCode: string) => {
|
||||||
|
if (!countryCode || countryCode === 'Unknown') return null
|
||||||
|
// * The API returns 2-letter country codes (e.g. US, DE)
|
||||||
|
// * We cast it to the flag component name
|
||||||
|
const FlagComponent = (Flags as any)[countryCode]
|
||||||
|
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCountryName = (code: string) => {
|
||||||
|
if (!code || code === 'Unknown') return 'Unknown'
|
||||||
|
try {
|
||||||
|
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
||||||
|
return regionNames.of(code) || code
|
||||||
|
} catch (e) {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (activeTab === 'countries') {
|
||||||
|
if (!countries || countries.length === 0) {
|
||||||
|
return <p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<WorldMap data={countries} />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{countries.map((country, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
|
<span className="shrink-0">{getFlagComponent(country.country)}</span>
|
||||||
|
<span className="truncate">{getCountryName(country.country)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||||
|
{formatNumber(country.pageviews)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'cities') {
|
||||||
|
if (!cities || cities.length === 0) {
|
||||||
|
return <p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{cities.map((city, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
|
<span className="shrink-0">{getFlagComponent(city.country)}</span>
|
||||||
|
<span className="truncate">{city.city === 'Unknown' ? 'Unknown' : city.city}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||||
|
{formatNumber(city.pageviews)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 h-full">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
|
Locations
|
||||||
|
</h3>
|
||||||
|
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('countries')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
activeTab === 'countries'
|
||||||
|
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Countries
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('cities')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
activeTab === 'cities'
|
||||||
|
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Cities
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
components/dashboard/TopPages.tsx
Normal file
40
components/dashboard/TopPages.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { formatNumber } from '@/lib/utils/format'
|
||||||
|
|
||||||
|
interface TopPagesProps {
|
||||||
|
pages: Array<{ path: string; pageviews: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopPages({ pages }: TopPagesProps) {
|
||||||
|
if (!pages || pages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
||||||
|
Top Pages
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-600 dark:text-neutral-400">No data available</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">
|
||||||
|
Top Pages
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pages.map((page, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 truncate text-neutral-900 dark:text-white">
|
||||||
|
{page.path}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||||
|
{formatNumber(page.pageviews)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user