gestion des providers

This commit is contained in:
fabien committed 2026-08-31 21:23:35 +02:00
1 parent 357bf9b41c
commit 4d72952b2c
46 files changed
+977 -132

No files matched your search

+5
View File
@@ -8,9 +8,11 @@ import (
"trankilou.fr/lassistanoque/backend/internal/adapter/auth/password" "trankilou.fr/lassistanoque/backend/internal/adapter/auth/password"
"trankilou.fr/lassistanoque/backend/internal/adapter/database" "trankilou.fr/lassistanoque/backend/internal/adapter/database"
"trankilou.fr/lassistanoque/backend/internal/adapter/file" "trankilou.fr/lassistanoque/backend/internal/adapter/file"
"trankilou.fr/lassistanoque/backend/internal/adapter/llm"
"trankilou.fr/lassistanoque/backend/internal/adapter/security" "trankilou.fr/lassistanoque/backend/internal/adapter/security"
"trankilou.fr/lassistanoque/backend/internal/http" "trankilou.fr/lassistanoque/backend/internal/http"
"trankilou.fr/lassistanoque/backend/internal/service/auth" "trankilou.fr/lassistanoque/backend/internal/service/auth"
"trankilou.fr/lassistanoque/backend/internal/service/provider"
"trankilou.fr/lassistanoque/backend/internal/service/storage" "trankilou.fr/lassistanoque/backend/internal/service/storage"
"trankilou.fr/lassistanoque/backend/internal/service/user" "trankilou.fr/lassistanoque/backend/internal/service/user"
) )
@@ -56,6 +58,7 @@ func runServe() {
// Adapters // Adapters
tokenManager := security.NewJwtTokenManager(12*time.Hour, 7*24*time.Hour, "lassistanoque") tokenManager := security.NewJwtTokenManager(12*time.Hour, 7*24*time.Hour, "lassistanoque")
pwdAuth := password.NewPasswordAuthenticator(tokenManager, db.UserRepository()) pwdAuth := password.NewPasswordAuthenticator(tokenManager, db.UserRepository())
llmEngine := llm.NewAnyLLMEngine()
// services // services
authService := auth.NewService( authService := auth.NewService(
@@ -67,6 +70,7 @@ func runServe() {
) )
userService := user.NewService(db.UserRepository()) userService := user.NewService(db.UserRepository())
storageService := storage.NewService(store) storageService := storage.NewService(store)
providerService := provider.NewService(db.ProviderRepository(), db.UserRepository(), llmEngine)
// http server // http server
router := http.NewRouter(http.Dependencies{ router := http.NewRouter(http.Dependencies{
@@ -74,6 +78,7 @@ func runServe() {
AuthService: authService, AuthService: authService,
UserService: userService, UserService: userService,
TokenManager: tokenManager, TokenManager: tokenManager,
ProviderService: providerService,
}) })
if err := router.Start(); err != nil { if err := router.Start(); err != nil {
@@ -12,6 +12,7 @@ type Database interface {
SettingsRepository() domain.SettingsRepository SettingsRepository() domain.SettingsRepository
UserRepository() domain.UserRepository UserRepository() domain.UserRepository
FileRepository() domain.FileRepository FileRepository() domain.FileRepository
ProviderRepository() domain.ProviderRepository
Migrate() error Migrate() error
Close() Close()
} }
@@ -67,3 +67,7 @@ func (db *TursoDB) SettingsRepository() domain.SettingsRepository {
func (db *TursoDB) FileRepository() domain.FileRepository { func (db *TursoDB) FileRepository() domain.FileRepository {
return NewTursoFileRepository(db.DB) return NewTursoFileRepository(db.DB)
} }
func (db *TursoDB) ProviderRepository() domain.ProviderRepository {
return NewTursoModelRepository(db.DB)
}
@@ -11,4 +11,3 @@ drop table tools;
drop table tasks; drop table tasks;
drop table history; drop table history;
drop table files; drop table files;
drop table share;
@@ -1,25 +1,16 @@
create table providers ( create table providers (
id text not null primary key, id text not null primary key,
team_id text not null, team_id text not null,
type text not null,
name text not null, name text not null,
key text, key text,
url text, url text,
models text,
_date_created numeric not null default current_timestamp, _date_created numeric not null default current_timestamp,
_date_updated numeric, _date_updated numeric,
_version text not null _version text not null
); );
create table models (
id text not null primary key,
team_id text not null,
provider_id text not null,
name text not null default '',
modelname text not null default '',
configuration text not null default '{}',
_date_created numeric not null default current_timestamp,
_date_updated numeric,
_version text not null
);
create table settings ( create table settings (
id text not null primary key, id text not null primary key,
@@ -150,14 +141,3 @@ create table files (
_date_updated numeric , _date_updated numeric ,
_version text not null _version text not null
); );
create table share (
id text not null primary key,
object_id text not null,
object_type text not null,
team_id text not null,
enabled numeric not null default 0,
_date_created numeric not null default current_timestamp,
_date_updated numeric,
_version text not null
);
@@ -0,0 +1,61 @@
package turso
import (
"database/sql"
"log"
"gitea.trankilou.fr/fabien/lasebuche"
"trankilou.fr/lassistanoque/backend/internal/domain"
)
type TursoModelRepository struct {
db *sql.DB
providerTable lasebuche.Table[domain.Provider]
}
func NewTursoModelRepository(db *sql.DB) *TursoModelRepository {
dialect := lasebuche.NewSqliteDialect()
providerTable, err := lasebuche.NewTable[domain.Provider](db, dialect, "providers")
if err != nil {
log.Fatalf("error creating lasebuche team table")
}
return &TursoModelRepository{
db: db,
providerTable: providerTable,
}
}
func (r *TursoModelRepository) ListProviders(userID string, teamID string) ([]*domain.Provider, error) {
return r.providerTable.SelectWhere(
"team_id=$1 and team_id in (select team_id from user_teams where user_id=$2)",
teamID,
userID,
)
}
func (r *TursoModelRepository) GetProvider(userID string, teamID string, id string) (*domain.Provider, error) {
return r.providerTable.SelectOne(
"id=$1 and team_id=$2 and team_id in (select team_id from user_teams where user_id=$3)",
id,
teamID,
userID,
)
}
func (r *TursoModelRepository) CreateProvider(userID string, provider *domain.Provider) (*domain.Provider, error) {
return r.providerTable.Insert(provider)
}
func (r *TursoModelRepository) UpdateProvider(userID string, provider *domain.Provider) (*domain.Provider, error) {
return r.providerTable.Update(provider)
}
func (r *TursoModelRepository) DeleteProvider(userID string, teamID string, id string) error {
return r.providerTable.DeleteWhere(
"id=$1 and team_id=$2 and team_id in (select team_id from user_teams where user_id=$3)",
id,
teamID,
userID,
)
}
@@ -73,7 +73,7 @@ func (ur *TursoUserRepository) FindTeam(userid string, teamid string) (*domain.T
} }
func (ur *TursoUserRepository) ListTeams(userid string) ([]*domain.Team, error) { func (ur *TursoUserRepository) ListTeams(userid string) ([]*domain.Team, error) {
return ur.TeamTable.SelectWhere("user_id=$1", userid) return ur.TeamTable.SelectWhere("id in (select team_id from user_teams where user_id=$1)", userid)
} }
func (ur *TursoUserRepository) CreateTeam(userid string, team *domain.Team) (*domain.Team, error) { func (ur *TursoUserRepository) CreateTeam(userid string, team *domain.Team) (*domain.Team, error) {
@@ -123,10 +123,6 @@ func (ur *TursoUserRepository) DeleteUserTeam(userid string, teamid string) erro
return ur.UserTeamTable.Delete(teamid) return ur.UserTeamTable.Delete(teamid)
} }
func (ur *TursoUserRepository) PopulateUserWithTeams(user *domain.User) (*domain.UserWithTeams, error) {
return nil, nil
}
func (ur *TursoUserRepository) ListUserAddresses(id string) ([]*domain.UserAddress, error) { func (ur *TursoUserRepository) ListUserAddresses(id string) ([]*domain.UserAddress, error) {
return ur.AddressTable.SelectWhere("user_id=$1", id) return ur.AddressTable.SelectWhere("user_id=$1", id)
} }
+76
View File
@@ -0,0 +1,76 @@
package llm
import (
"context"
"fmt"
anyllm "github.com/mozilla-ai/any-llm-go"
"github.com/mozilla-ai/any-llm-go/providers/anthropic"
"github.com/mozilla-ai/any-llm-go/providers/ollama"
"github.com/mozilla-ai/any-llm-go/providers/openai"
"trankilou.fr/lassistanoque/backend/internal/domain"
)
var providerTypes = []domain.Item{
{ID: "anthropic", Text: "Anthropic"},
{ID: "openai", Text: "OpenAI"},
{ID: "ollama", Text: "Ollama"},
{ID: "openaicomp", Text: "OpenAI compatible"},
{ID: "openrouter", Text: "Openrouter"},
}
type AnyLLMEngine struct {
}
func NewAnyLLMEngine() *AnyLLMEngine {
return &AnyLLMEngine{}
}
func (e *AnyLLMEngine) ListProviderTypes() []domain.Item {
return providerTypes
}
func providerFactory(provider *domain.Provider) (anyllm.Provider, error) {
switch provider.Type {
case "ollama":
return ollama.New(
anyllm.WithBaseURL(provider.URL),
)
case "openai":
return openai.New(
anyllm.WithAPIKey(provider.APIKey),
)
case "openaicomp":
return openai.New(
anyllm.WithBaseURL(provider.URL),
anyllm.WithAPIKey(provider.APIKey),
)
case "anthropic":
return anthropic.New(
anyllm.WithAPIKey(provider.APIKey),
)
}
return nil, fmt.Errorf("unknown provider type: %s", provider.Type)
}
func (e *AnyLLMEngine) ListModelsFromProvider(ctx context.Context, provider *domain.Provider) ([]string, error) {
models := make([]string, 0)
prov, err := providerFactory(provider)
if err != nil {
return nil, err
}
if lister, ok := prov.(anyllm.ModelLister); ok {
response, err := lister.ListModels(ctx)
if err != nil {
return nil, err
}
for _, m := range response.Data {
models = append(models, m.ID)
}
} else {
models = append(models, "Default")
}
return models, nil
}
+26
View File
@@ -0,0 +1,26 @@
package domain
import "context"
type LLMModelDetail struct {
ParentModel string `json:"parent_model"`
Format string `json:"format"`
Family string `json:"family"`
Families []string `json:"families"`
ParameterSize string `json:"parameter_size"`
QuantizationLevel string `json:"quantization_level"`
}
type LLMModel struct {
Name string `json:"name"`
Model string `json:"model"`
ModifiedAt int64 `json:"modified_at"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Detail *LLMModelDetail `json:"detail"`
}
type LLMEngine interface {
ListProviderTypes() []Item
ListModelsFromProvider(ctx context.Context, provider *Provider) ([]string, error)
}
+24
View File
@@ -0,0 +1,24 @@
package domain
import "time"
type Provider struct {
ID string `db:"id" json:"id"`
TeamID string `db:"team_id" json:"teamId"`
Type string `db:"type" json:"type"`
Name string `db:"name" json:"name"`
APIKey string `db:"key" json:"apiKey"`
URL string `db:"url" json:"baseUrl"`
Models string `db:"models" json:"models"`
DateCreated time.Time `db:"_date_created" json:"_date_created"`
DateUpdated *time.Time `db:"_date_updated" json:"_date_updated"`
VersionId string `db:"_version" json:"_version"`
}
type ProviderRepository interface {
ListProviders(userID string, teamID string) ([]*Provider, error)
GetProvider(userID string, teamID string, id string) (*Provider, error)
CreateProvider(userID string, provider *Provider) (*Provider, error)
UpdateProvider(userID string, provider *Provider) (*Provider, error)
DeleteProvider(userID string, teamID string, id string) error
}
+6
View File
@@ -0,0 +1,6 @@
package domain
type Item struct {
ID string `json:"id"`
Text string `json:"text"`
}
+13 -14
View File
@@ -15,27 +15,27 @@ type User struct {
Theme string `db:"theme" json:"theme"` Theme string `db:"theme" json:"theme"`
Lang string `db:"lang" json:"lang"` Lang string `db:"lang" json:"lang"`
Timezone string `db:"timezone" json:"timezone"` Timezone string `db:"timezone" json:"timezone"`
DateCreated time.Time `db:"_date_created" json:"_date_created"` DateCreated time.Time `db:"_date_created" json:"_dateCreated"`
DateUpdated *time.Time `db:"_date_updated" json:"_date_updated"` DateUpdated *time.Time `db:"_date_updated" json:"_dateUpdated"`
VersionId string `db:"_version" json:"_version"` VersionId string `db:"_version" json:"_version"`
} }
type Team struct { type Team struct {
ID string `db:"id" json:"id"` ID string `db:"id" json:"id"`
Label string `db:"label" json:"label"` Label string `db:"label" json:"label"`
DefaultModelID *string `db:"default_model_id" json:"default_model_id"` DefaultModelID *string `db:"default_model_id" json:"defaultModelId"`
DateCreated time.Time `db:"_date_created" json:"_date_created"` DateCreated time.Time `db:"_date_created" json:"_dateCreated"`
DateUpdated *time.Time `db:"_date_updated" json:"_date_updated"` DateUpdated *time.Time `db:"_date_updated" json:"_dateUpdated"`
VersionId string `db:"_version" json:"_version"` VersionId string `db:"_version" json:"_version"`
} }
type UserTeam struct { type UserTeam struct {
ID string `db:"id" json:"id"` ID string `db:"id" json:"id"`
UserID string `db:"user_id" json:"user_id"` UserID string `db:"user_id" json:"userId"`
TeamID string `db:"team_id" json:"team_id"` TeamID string `db:"team_id" json:"teamId"`
Administrator bool `db:"administrator" json:"administrator"` Administrator bool `db:"administrator" json:"administrator"`
DateCreated time.Time `db:"_date_created" json:"_date_created"` DateCreated time.Time `db:"_date_created" json:"_dateCreated"`
DateUpdated *time.Time `db:"_date_updated" json:"_date_updated"` DateUpdated *time.Time `db:"_date_updated" json:"_dateUpdated"`
VersionId string `db:"_version" json:"_version"` VersionId string `db:"_version" json:"_version"`
} }
@@ -44,8 +44,8 @@ type UserAddress struct {
UserID string `db:"user_id" json:"user_id"` UserID string `db:"user_id" json:"user_id"`
Type string `db:"type" json:"type"` Type string `db:"type" json:"type"`
Address string `db:"address" json:"address"` Address string `db:"address" json:"address"`
DateCreated time.Time `db:"_date_created" json:"_date_created"` DateCreated time.Time `db:"_date_created" json:"_dateCreated"`
DateUpdated *time.Time `db:"_date_updated" json:"_date_updated"` DateUpdated *time.Time `db:"_date_updated" json:"_dateUpdated"`
VersionId string `db:"_version" json:"_version"` VersionId string `db:"_version" json:"_version"`
} }
@@ -56,7 +56,8 @@ type TeamRelation struct {
type UserWithTeams struct { type UserWithTeams struct {
User *User `json:"user"` User *User `json:"user"`
Teams []*TeamRelation `json:"teams"` UserTeams []*UserTeam `json:"user_teams"`
Teams []*Team `json:"teams"`
} }
func AddressTypes() []string { func AddressTypes() []string {
@@ -88,8 +89,6 @@ type UserRepository interface {
UpdateUserTeam(userid string, team *UserTeam) (*UserTeam, error) UpdateUserTeam(userid string, team *UserTeam) (*UserTeam, error)
DeleteUserTeam(userid string, teamid string) error DeleteUserTeam(userid string, teamid string) error
PopulateUserWithTeams(user *User) (*UserWithTeams, error)
ListUserAddresses(userid string) ([]*UserAddress, error) ListUserAddresses(userid string) ([]*UserAddress, error)
GetUserAddress(addrID string) (*UserAddress, error) GetUserAddress(addrID string) (*UserAddress, error)
CreateUserAddress(addr *UserAddress) (*UserAddress, error) CreateUserAddress(addr *UserAddress) (*UserAddress, error)
+123
View File
@@ -0,0 +1,123 @@
package handlers
import (
"net/http"
"github.com/labstack/echo/v5"
"trankilou.fr/lassistanoque/backend/internal/domain"
"trankilou.fr/lassistanoque/backend/internal/service/auth"
"trankilou.fr/lassistanoque/backend/internal/service/provider"
)
func NewModelGroup(prefix string, e *echo.Group, service *provider.Service, middlewares ...echo.MiddlewareFunc) *echo.Group {
modelHandler := &ModelHandler{
providerService: service,
}
model := e.Group(prefix, middlewares...)
model.GET("/providerTypes", modelHandler.ListProviderTypes)
model.GET("/:space", modelHandler.ListProviders)
model.POST("/:space/avalable-models", modelHandler.ListAvailableModels)
model.GET("/:space/:provider", modelHandler.GetProvider)
model.PUT("/:space", modelHandler.UpdateProvider)
model.POST("/:space", modelHandler.CreateProvider)
model.DELETE("/:space/:provider", modelHandler.DeleteProvider)
return model
}
type ModelHandler struct {
providerService *provider.Service
}
func (h *ModelHandler) ListProviderTypes(c *echo.Context) error {
return c.JSON(http.StatusOK, h.providerService.ListProviderTypes())
}
func (h *ModelHandler) ListProviders(c *echo.Context) error {
userID := c.Get(auth.ContextUserIDKey).(string)
teamID := c.Param("space")
providers, err := h.providerService.ListProviders(userID, teamID)
if err != nil {
c.Logger().Error("error listing providers: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, "error listing providers")
}
return c.JSON(http.StatusOK, providers)
}
func (h *ModelHandler) GetProvider(c *echo.Context) error {
userID := c.Get(auth.ContextUserIDKey).(string)
teamID := c.Param("space")
providerID := c.Param("provider")
provider, err := h.providerService.GetProvider(userID, teamID, providerID)
if err != nil {
c.Logger().Error("error getting provider: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, "error getting provider")
}
return c.JSON(http.StatusOK, provider)
}
func (h *ModelHandler) CreateProvider(c *echo.Context) error {
userID := c.Get(auth.ContextUserIDKey).(string)
teamID := c.Param("space")
var provider domain.Provider
err := c.Bind(&provider)
if err != nil {
c.Logger().Error("error binding provider: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, "error binding provider")
}
provider.TeamID = teamID
updprovider, err := h.providerService.CreateProvider(userID, &provider)
if err != nil {
c.Logger().Error("error creating provider: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, "error creating provider")
}
return c.JSON(http.StatusOK, updprovider)
}
func (h *ModelHandler) UpdateProvider(c *echo.Context) error {
userID := c.Get(auth.ContextUserIDKey).(string)
teamID := c.Param("space")
var provider domain.Provider
err := c.Bind(&provider)
if err != nil {
c.Logger().Error("error binding provider: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, "error binding provider")
}
provider.TeamID = teamID
updprovider, err := h.providerService.UpdateProvider(userID, &provider)
if err != nil {
c.Logger().Error("error updating provider: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, "error creating provider")
}
return c.JSON(http.StatusOK, updprovider)
}
func (h *ModelHandler) DeleteProvider(c *echo.Context) error {
userID := c.Get(auth.ContextUserIDKey).(string)
teamID := c.Param("space")
providerID := c.Param("provider")
err := h.providerService.DeleteProvider(userID, teamID, providerID)
if err != nil {
c.Logger().Error("error creating provider: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, "error creating provider")
}
return c.JSON(http.StatusOK, providerID)
}
func (h *ModelHandler) ListAvailableModels(c *echo.Context) error {
teamID := c.Param("space")
var provider domain.Provider
err := c.Bind(&provider)
if err != nil {
c.Logger().Error("error binding provider: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, "error binding provider")
}
provider.TeamID = teamID
list, err := h.providerService.ListAvailableModels(c.Request().Context(), &provider)
if err != nil {
c.Logger().Error("error listing models: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, "error creating provider")
}
return c.JSON(http.StatusOK, list)
}
+1 -2
View File
@@ -13,7 +13,6 @@ func NewUserGroup(prefix string, e *echo.Group, service *user.Service, middlewar
userHandler := &UserHandler{ userHandler := &UserHandler{
userService: service, userService: service,
} }
_ = userHandler
auth := e.Group(prefix, middlewares...) auth := e.Group(prefix, middlewares...)
auth.GET("/me", userHandler.Me) auth.GET("/me", userHandler.Me)
auth.PUT("/:id", userHandler.Update) auth.PUT("/:id", userHandler.Update)
@@ -30,7 +29,7 @@ type UserHandler struct {
func (h UserHandler) Me(c *echo.Context) error { func (h UserHandler) Me(c *echo.Context) error {
userID := c.Get(auth.ContextUserIDKey).(string) userID := c.Get(auth.ContextUserIDKey).(string)
user, err := h.userService.GetUser(userID) user, err := h.userService.GetUserWithTeams(userID)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) return echo.NewHTTPError(http.StatusUnauthorized, err.Error())
} }
+3
View File
@@ -11,6 +11,7 @@ import (
"trankilou.fr/lassistanoque/backend/internal/config" "trankilou.fr/lassistanoque/backend/internal/config"
"trankilou.fr/lassistanoque/backend/internal/http/handlers" "trankilou.fr/lassistanoque/backend/internal/http/handlers"
"trankilou.fr/lassistanoque/backend/internal/service/auth" "trankilou.fr/lassistanoque/backend/internal/service/auth"
"trankilou.fr/lassistanoque/backend/internal/service/provider"
"trankilou.fr/lassistanoque/backend/internal/service/storage" "trankilou.fr/lassistanoque/backend/internal/service/storage"
"trankilou.fr/lassistanoque/backend/internal/service/user" "trankilou.fr/lassistanoque/backend/internal/service/user"
) )
@@ -35,6 +36,7 @@ type Dependencies struct {
StorageService *storage.Service StorageService *storage.Service
AuthService *auth.Service AuthService *auth.Service
UserService *user.Service UserService *user.Service
ProviderService *provider.Service
TokenManager auth.TokenManager TokenManager auth.TokenManager
} }
@@ -71,6 +73,7 @@ func NewRouter(deps Dependencies) *Router {
_ = handlers.NewAuthGroup("/auth", api, deps.AuthService) _ = handlers.NewAuthGroup("/auth", api, deps.AuthService)
_ = handlers.NewUserGroup("/user", api, deps.UserService, deps.TokenManager.TokenMiddleware) _ = handlers.NewUserGroup("/user", api, deps.UserService, deps.TokenManager.TokenMiddleware)
_ = handlers.NewMiscGroup("/misc", api, deps.TokenManager.TokenMiddleware) _ = handlers.NewMiscGroup("/misc", api, deps.TokenManager.TokenMiddleware)
_ = handlers.NewModelGroup("/provider", api, deps.ProviderService, deps.TokenManager.TokenMiddleware)
return &Router{ return &Router{
echo: e, echo: e,
@@ -0,0 +1,76 @@
package provider
import (
"context"
"fmt"
"trankilou.fr/lassistanoque/backend/internal/domain"
)
type Service struct {
repo domain.ProviderRepository
repoUser domain.UserRepository
llmengine domain.LLMEngine
}
func NewService(
repo domain.ProviderRepository,
repoUser domain.UserRepository,
llmengine domain.LLMEngine,
) *Service {
return &Service{
repo,
repoUser,
llmengine,
}
}
func (s *Service) ListProviderTypes() []domain.Item {
return s.llmengine.ListProviderTypes()
}
func (s *Service) ListProviders(userID string, teamID string) ([]*domain.Provider, error) {
return s.repo.ListProviders(userID, teamID)
}
func (s *Service) GetProvider(userID string, teamID string, id string) (*domain.Provider, error) {
return s.repo.GetProvider(userID, teamID, id)
}
func (s *Service) CreateProvider(userID string, provider *domain.Provider) (*domain.Provider, error) {
if _, err := s.repoUser.FindUserTeam(userID, provider.TeamID); err != nil {
return nil, fmt.Errorf("error finding user in team: %s", err)
}
return s.repo.CreateProvider(userID, provider)
}
func (s *Service) UpdateProvider(userID string, provider *domain.Provider) (*domain.Provider, error) {
if _, err := s.repoUser.FindUserTeam(userID, provider.TeamID); err != nil {
return nil, fmt.Errorf("error finding user in team: %s", err)
}
if _, err := s.repo.GetProvider(userID, provider.TeamID, provider.ID); err != nil {
return nil, fmt.Errorf("error finding provider: %s", err)
}
return s.repo.UpdateProvider(userID, provider)
}
func (s *Service) DeleteProvider(userID string, teamID string, id string) error {
if _, err := s.repoUser.FindUserTeam(userID, teamID); err != nil {
return fmt.Errorf("error finding user in team: %s", err)
}
if _, err := s.repo.GetProvider(userID, teamID, id); err != nil {
return fmt.Errorf("error finding provider: %s", err)
}
return s.repo.DeleteProvider(userID, teamID, id)
}
func (s *Service) ListAvailableModels(ctx context.Context, provider *domain.Provider) ([]string, error) {
return s.llmengine.ListModelsFromProvider(ctx, provider)
}
+24
View File
@@ -27,6 +27,30 @@ func (s *Service) GetUser(id string) (*domain.User, error) {
return user, nil return user, nil
} }
func (s *Service) GetUserWithTeams(id string) (*domain.UserWithTeams, error) {
u, err := s.GetUser(id)
if err != nil {
return nil, err
}
teams, err := s.repo.ListTeams(u.ID)
if err != nil {
return nil, err
}
userTeams, err := s.repo.ListUserTeams(u.ID)
if err != nil {
return nil, err
}
ut := &domain.UserWithTeams{
User: u,
UserTeams: userTeams,
Teams: teams,
}
return ut, nil
}
func (s *Service) UpdateUser(updUser *domain.User, fromAdmin bool) (*domain.User, error) { func (s *Service) UpdateUser(updUser *domain.User, fromAdmin bool) (*domain.User, error) {
user, err := s.repo.FindUser(updUser.ID) user, err := s.repo.FindUser(updUser.ID)
if err != nil { if err != nil {
Binary file not shown.
@@ -0,0 +1,13 @@
import { api } from '../client';
import type { Provider, Item } from "$lib/types/api"
import { space } from "$lib/state/space.svelte"
export const providerApi = {
providerTypes: () => api.get<Item[]>('/provider/providerTypes'),
getProviders: () => api.get<Provider[]>(`/provider/${space.id}`),
getProvider: (id: string) => api.get<Provider>(`/provider/${space.id}/${id}`),
createProvider: (data: Partial<Provider>) => api.post<Provider>(`/provider/${space.id}`, data),
updateProvider: (data: Partial<Provider>) => api.put<Provider>(`/provider/${space.id}`, data),
deleteProvider: (id: string) => api.delete(`/provider/${space.id}/${id}`),
getAvailableModels: (data: Partial<Provider>) => api.post<string[]>(`/provider/${space.id}/avalable-models`, data),
};
+2 -2
View File
@@ -1,8 +1,8 @@
import { api } from '../client'; import { api } from '../client';
import type { User, Address } from "$lib/types/api" import type { User, Address, UserWithTeams } from "$lib/types/api"
export const userApi = { export const userApi = {
me: () => api.get<User>('/user/me'), me: () => api.get<UserWithTeams>('/user/me'),
update: (id: string, data: Partial<User>) => api.put<User>(`/user/${id}`, data), update: (id: string, data: Partial<User>) => api.put<User>(`/user/${id}`, data),
listAddresses: (id: string) => api.get<Address[]>(`/user/${id}/address`), listAddresses: (id: string) => api.get<Address[]>(`/user/${id}/address`),
@@ -8,6 +8,4 @@
{#if errors.has(key)} {#if errors.has(key)}
<span class="text-red-700 dark:text-red-400 text-xs">{errors.get(key)}</span> <span class="text-red-700 dark:text-red-400 text-xs">{errors.get(key)}</span>
{:else}
<span class="text-xs">&nbsp;</span>
{/if} {/if}
+27 -12
View File
@@ -3,6 +3,7 @@
import Menu from "$lib/components/Menu.svelte" import Menu from "$lib/components/Menu.svelte"
import { logout } from "$lib/state/auth.svelte" import { logout } from "$lib/state/auth.svelte"
import logo from "$lib/assets/logo-txt.png" import logo from "$lib/assets/logo-txt.png"
import { space } from '$lib/state/space.svelte'
let {handleMenuDesktop, handleMenuMobile} = $props(); let {handleMenuDesktop, handleMenuMobile} = $props();
@@ -12,7 +13,7 @@
</script> </script>
<div class="flex flex-row italic mb-4"> <div class="flex flex-row italic mb-2">
{#if uiState.menuContentVisible} {#if uiState.menuContentVisible}
<div class="flex-1 p-3 font openonly"> <div class="flex-1 p-3 font openonly">
<img src={logo} class="h-5" alt="logo"/> <img src={logo} class="h-5" alt="logo"/>
@@ -25,24 +26,38 @@
<i class="icon-menu pointer block p-2"></i> <i class="icon-menu pointer block p-2"></i>
</button> </button>
</div> </div>
{#if uiState.menuContentVisible}
<div class="m-2">
<div class="formelement">
<select class="textinput">
{#each space.spaces as s (s.id)}
<option value={s.id}>{s.label}</option>
{/each}
</select>
</div>
</div>
{/if}
<div id="menu" class="flex-1 overflow-y-scroll"> <div id="menu" class="flex-1 overflow-y-scroll">
<Menu label="Conversation" icon="icon-chat" link="/"/> <Menu label="Nouveau chat" icon="icon-plus" link="/"/>
<Menu label="Tâches" icon="icon-tasks" link="/tasks"/> <Menu label="Conversations" icon="icon-chat" link="/history"/>
<Menu label="Planification" icon="icon-tasks" link="/tasks"/>
<Menu label="Connaissances" icon="icon-graduation-cap" link="/kb"/> <Menu label="Connaissances" icon="icon-graduation-cap" link="/kb"/>
<Menu label="Historique" icon="icon-history" link="/history"/> <Menu label="Réglages" icon="icon-cog" link="/settings"/>
<Menu label="Paramétrage" icon="icon-cog" link="/settings"/>
</div> </div>
<div id="profile" class="w-full flex flex-row items-center border-t border-stone-200 dark:border-stone-800"> <div id="profile" class="w-full items-center border-t border-stone-200 dark:border-stone-800">
{#if uiState.menuContentVisible} {#if uiState.menuContentVisible}
<div class="p-4 flex-1 profile"> <div class="flex flex-row gap-2 p-2 ">
<div>Fabien Masson</div> <div class="py-1 flex-1 flex flex-row text-xs">
<div class="flex"> <div class="w-6 h-6 rounded-full bg-cyan-600 text-white flex items-center justify-center font-medium">
<a href="/profile" class="text-xs flex-1">Modifier</a> FM
</div> </div>
<div class="flex-1 ml-2 inline-flex items-center">Fabien Masson</div>
</div> </div>
<div class="p-2"> <a href="/profile" class="bt small inline-flex items-center justify-center" title="Edition profil">
<button class="w-full bt" onclick={handleLogout} title="Déconecter"> <i class="icon-edit"></i>
</a>
<button class="bt small" onclick={handleLogout} title="Déconnecter">
<i class="icon-logout"></i> <i class="icon-logout"></i>
</button> </button>
</div> </div>
+3 -5
View File
@@ -1,15 +1,13 @@
import { type User } from "$lib/types/api" import type { User } from "$lib/types/api"
type AuthState = { type AuthState = {
user: User | null; user: User | null
status: 'loading' | 'authenticated' | 'unauthenticated'; status: 'loading' | 'authenticated' | 'unauthenticated'
//accessToken: string | null
} }
export const auth: AuthState = $state({ export const auth: AuthState = $state({
user: null, user: null,
status: 'loading', status: 'loading',
//accessToken: null
}) })
export const logout = () => { export const logout = () => {
@@ -0,0 +1,6 @@
import type { Team } from "$lib/types/api"
export const space = $state({
spaces: [] as Team[],
id: "",
});
+50
View File
@@ -1,3 +1,8 @@
export type Item = {
id: string
text: string
}
export interface User { export interface User {
id: string id: string
firstname: string firstname: string
@@ -9,9 +14,54 @@ export interface User {
_version: string _version: string
} }
export interface UserTeam {
id: string
userId: string
teamId: string
administrator: boolean
_version: string
}
export interface Team {
id: string
label: string
defaultModelId: string
_version: string
}
export interface UserWithTeams {
user: User
userTeams: UserTeam[]
teams: Team[]
}
export interface Address { export interface Address {
id: string id: string
type: string type: string
address: string address: string
_version: string _version: string
} }
export interface Provider {
id: string
teamId: string
type: string
name: string
apiKey: string
baseUrl: string
models: string
_date_created: string
_date_updated: string
_version: string
}
export interface Model {
id: string
teamId: string
providerId: string
modelName: string
configuration: object
_date_created: string
_date_updated: string
_version: string
}
@@ -1,5 +1,10 @@
const emailRegex: RegExp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const emailRegex: RegExp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const urlRegex: RegExp = /^https?:\/\/([a-zA-Z0-9.-]+|\[[0-9a-fA-F:.]+\])(:\d+)?(\/[^\s]*)?$/;
export function validateEmail(input: string): boolean { export function validateEmail(input: string): boolean {
return emailRegex.test(input); return emailRegex.test(input);
} }
export function validateUrl(input: string): boolean {
return urlRegex.test(input);
}
@@ -1,7 +1,8 @@
<div class="container">
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur? Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur?
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur? Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur?
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur? Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur?
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur? Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur?
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur? Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur?
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur? Lorem ipsum dolor sit amet consectetur, adipisicing elit. Asperiores, reiciendis quo. Quos animi nisi modi cumque hic illo doloremque totam! Distinctio molestias sint magnam illum nostrum eos, fugit aliquid consequuntur?
</div>
@@ -3,6 +3,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import {uiState} from "$lib/state/ui.svelte" import {uiState} from "$lib/state/ui.svelte"
import { auth } from '$lib/state/auth.svelte' import { auth } from '$lib/state/auth.svelte'
import { space } from '$lib/state/space.svelte'
import Sidebar from "$lib/components/Sidebar.svelte" import Sidebar from "$lib/components/Sidebar.svelte"
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { onMount } from "svelte"; import { onMount } from "svelte";
@@ -39,7 +40,9 @@
onMount(async () => { onMount(async () => {
handleResize() handleResize()
if (data.me) { if (data.me) {
auth.user = data.me auth.user = data.me.user
space.spaces = data.me.teams
space.id = data.me.teams[0].id
themeToLocalStorage(auth.user.theme) themeToLocalStorage(auth.user.theme)
applyTheme() applyTheme()
auth.status = 'authenticated' auth.status = 'authenticated'
@@ -64,7 +67,7 @@
</div> </div>
<div id="main"> <div id="main">
<div class="flex flex-col h-screen "> <div class="flex flex-col h-screen ">
<div class={["header", page.data.theme]}> <div class={["header", "container" , page.data.theme]}>
<h1 class="flex"> <h1 class="flex">
<span class="flex-1">{page.data.title}</span> <span class="flex-1">{page.data.title}</span>
{#if page.data.back} {#if page.data.back}
@@ -2,7 +2,7 @@
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import type { User, Address } from '$lib/types/api' import type { User, Address, Item } from '$lib/types/api'
import { userApi } from '$lib/api/endpoints/user' import { userApi } from '$lib/api/endpoints/user'
import { miscApi } from '$lib/api/endpoints/misc' import { miscApi } from '$lib/api/endpoints/misc'
import FormError from '$lib/components/FormError.svelte'; import FormError from '$lib/components/FormError.svelte';
@@ -13,15 +13,15 @@
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let form : User = $state(data.me) let form : User = $state(data.me.user)
let addresses : Address[] = $state([] as Address[]) let addresses : Address[] = $state([] as Address[])
let newAddress : Address = $state({} as Address) let newAddress : Address = $state({} as Address)
let timezones = $state([] as string[]) let timezones = $state([] as Item[])
let errors = new SvelteMap<string,string>() let errors = new SvelteMap<string,string>()
onMount(async ()=> { onMount(async ()=> {
addresses = await userApi.listAddresses(form.id) addresses = await userApi.listAddresses(form.id)
timezones = await miscApi.listTimezones() timezones = (await miscApi.listTimezones()).map((it)=>{return {id:it, text:it}})
}) })
$effect(()=>{ $effect(()=>{
@@ -64,12 +64,13 @@
</script> </script>
<div class="mb-4 formgroup"> <div class="mb-4 formgroup container">
<h2>Mon compte</h2> <h2>Mon compte</h2>
<form onsubmit={submitUser} onchange={()=>{errors.clear()}}> <form onsubmit={submitUser} onchange={()=>{errors.clear()}}>
<label for="email" class="block my-1 flex-1">Email</label> <div class="formelement">
<label for="email" class="my-1 flex-1">Email</label>
<input <input
id="email" id="email"
type="text" type="text"
@@ -78,10 +79,11 @@
autofocus autofocus
/> />
<FormError key="email" errors={errors} /> <FormError key="email" errors={errors} />
</div>
<div class="flex"> <div class="grid grid-cols-1 gap-x-4 md:grid-cols-2">
<div class="flex-1 mr-2"> <div class="formelement">
<label for="firstname" class="block my-1 flex-1">Prénom</label> <label for="firstname" class=" my-1 flex-1">Prénom</label>
<input <input
id="firstname" id="firstname"
type="text" type="text"
@@ -91,8 +93,8 @@
<FormError key="firstname" errors={errors} /> <FormError key="firstname" errors={errors} />
</div> </div>
<div class="flex-1 ml-2"> <div class="formelement">
<label for="lastname" class="block my-1 flex-1">Nom</label> <label for="lastname" class="my-1 flex-1">Nom</label>
<input <input
id="lastname" id="lastname"
type="text" type="text"
@@ -103,9 +105,9 @@
</div> </div>
</div> </div>
<div class="flex"> <div class="grid grid-cols-1 gap-x-4 md:grid-cols-2">
<div class="flex-1 mr-2"> <div class="formelement">
<label for="lang" class="block my-1 flex-1">Langue</label> <label for="lang" class="block my-1 flex-1">Langue</label>
<select bind:value={form.lang} class="textinput"> <select bind:value={form.lang} class="textinput">
<option value="en">English</option> <option value="en">English</option>
@@ -114,14 +116,14 @@
<FormError key="lang" errors={errors} /> <FormError key="lang" errors={errors} />
</div> </div>
<div class="flex-1 ml-2"> <div class="formelement">
<label for="lang" class="block my-1 flex-1">Timezone</label> <label for="timezone" class="block my-1 flex-1">Timezone</label>
<Select items={timezones} bind:value={form.timezone}/> <Select items={timezones} bind:value={form.timezone}/>
<FormError key="lang" errors={errors} /> <FormError key="timezone" errors={errors} />
</div> </div>
</div> </div>
<div class="flex-1 mr-2"> <div class="formelement">
<label for="theme" class="block my-1 flex-1">Thème</label> <label for="theme" class="block my-1 flex-1">Thème</label>
<select bind:value={form.theme} class="textinput"> <select bind:value={form.theme} class="textinput">
<option value="auto">Automatique</option> <option value="auto">Automatique</option>
@@ -137,14 +139,14 @@
</div> </div>
<div class="mb-4 formgroup"> <div class="mb-4 formgroup container">
<h2>Canaux de communication</h2> <h2>Canaux de communication</h2>
{#each addresses as address (address.id) } {#each addresses as address (address.id) }
<div class="m-2"> <div class="m-2">
<div class="dark:bg-stone-700 bg-stone-300 rounded flex inline px-2 py-1"> <div class="dark:bg-stone-700 bg-stone-300 rounded inline px-2 py-1 text-sm">
<span class="inline-block w-20">{address.type} : </span> <span class="inline-block w-20">{address.type} : </span>
<span class="font-bold">{address.address}</span> <span class="">{address.address}</span>
<button class="ml-4 pointer" onclick={()=>{handleDeleteAddress(address.id)}}> <button class="ml-4 pointer" onclick={()=>{handleDeleteAddress(address.id)}}>
<i class="icon-cancel"></i> <i class="icon-cancel"></i>
</button> </button>
@@ -1,4 +1,4 @@
<div class="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4"> <div class="container grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4">
<a class="bt p-5 text-center" <a class="bt p-5 text-center"
title="Modèles" title="Modèles"
@@ -1 +1,157 @@
<div>Modèles</div> <script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import type { Provider} from '$lib/types/api'
import type { PageProps } from './$types';
import { providerApi } from "$lib/api/endpoints/provider"
import { onMount } from 'svelte';
import { validateUrl } from '$lib/utilities/validators'
import ProviderForm from './ProviderForm.svelte'
let { data }: PageProps = $props();
let providers = $state([] as Provider[])
let addingProvider = $state(false)
let editingProvider = $state(null as string|null)
let providerForm = $state({} as Provider)
let errors = new SvelteMap<string,string>()
onMount(async ()=>{
providers = await providerApi.getProviders()
})
const enterAddProviderHandler = () => {
if (editingProvider) return
errors.clear()
providerForm = {} as Provider
providerForm.models = ""
addingProvider= true
}
const cancelAddProviderHandler = () => {
addingProvider= false
}
const submitAddProvider = (p: Provider) => {
if (!p.name) {
errors.set("name","Le nom est obligatoire !")
}
if (!p.type) {
errors.set("type","Le type est obligatoire !")
}
if (p.baseUrl && !validateUrl(p.baseUrl)) {
errors.set("baseUrl","Mauvaise URL !")
}
if (errors.size === 0) {
providerApi.createProvider(p)
.then((p)=>{
providers.push(p)
addingProvider= false
providerForm = {} as Provider
})
.catch((err)=>{
console.log(err)
})
}
}
const cancelEditProviderHandler = () => {
editingProvider = null
providerForm = {} as Provider
}
const submitEditProvider = (p: Provider) => {
if (!p.name) {
errors.set("name","Le nom est obligatoire !")
}
if (!p.type) {
errors.set("type","Le type est obligatoire !")
}
if (p.baseUrl && !validateUrl(p.baseUrl)) {
errors.set("baseUrl","Mauvaise URL !")
}
if (errors.size === 0) {
providerApi.updateProvider(p)
.then((p)=>{
providers = providers.map((it)=>{return p.id===it.id ? p : it})
editingProvider = null
providerForm = {} as Provider
})
.catch((err)=>{
console.log(err)
})
}
}
const handleEditProvider = async (id: string) => {
errors.clear()
if (addingProvider) return
providerApi.getProvider(id)
.then((data)=>{
providers = providers.map((it)=>it.id===id ? data : it)
editingProvider = id
})
}
const handleDeleteProvider = (id: string) => {
providerApi.deleteProvider(id)
.then(() => {
providers = providers.filter((it)=>it.id!==id)
})
.catch((err) => {console.log(err)})
}
</script>
<div class="container">
{#if addingProvider}
{#key "new"}
<ProviderForm
item={providerForm}
errors={errors}
providerTypes={data.providerTypes}
onSave={submitAddProvider}
onCancel={cancelAddProviderHandler}
/>
{/key}
{:else}
<div class="rounded border border-dashed p-2 border-stone-500 cursor-pointer" onclick={enterAddProviderHandler} tabindex="0">
<i class="icon-plus"></i>
Ajouter un fournisseur
</div>
{/if}
{#each providers as p (p.id) }
{#if editingProvider===p.id}
{#key p.id}
<ProviderForm
item={p}
errors={errors}
providerTypes={data.providerTypes}
onSave={submitEditProvider}
onCancel={cancelEditProviderHandler}
/>
{/key}
{:else}
<div class="rounded border p-2 my-2 border-stone-500 flex">
<div class="flex-1">
<span>{ p.name }</span>
<span class="text-stone-500">({ p.type })</span>
</div>
<button class="bt small mx-1" onclick={()=>handleEditProvider(p.id)}>
<i class="icon-edit"></i>
</button>
<button class="bt small mx-1" onclick={()=>handleDeleteProvider(p.id)}>
<i class="icon-trash-empty"></i>
</button>
</div>
{/if}
{/each}
</div>
@@ -1,7 +1,11 @@
export function load() { import { providerApi } from "$lib/api/endpoints/provider"
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
return { return {
title: 'Paramétrage : Modèles', title: 'Paramétrage : Modèles',
back: "/settings", back: "/settings",
theme: "settings" theme: "settings",
providerTypes: await providerApi.providerTypes(),
}; };
} }
@@ -0,0 +1,142 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import FormError from '$lib/components/FormError.svelte';
import Select from 'svelte-select/no-styles';
import type { Provider, Item } from "$lib/types/api"
import { providerApi } from "$lib/api/endpoints/provider"
type Props = {
item: Provider
errors: Map<string,string>
providerTypes: Item[]
onSave: (item: Provider) => void
onCancel: () => void
}
let {
item,
errors,
providerTypes,
onSave,
onCancel,
}: Props = $props();
type Selectable = {
value: string
selected: boolean
}
let selectedModels = $state(item.models.split(";"))
let availableModels : Selectable[]= $state(item.models.split(";").map((it)=> {return {value:it, selected:true}}))
let modelsErrors = new SvelteMap<string,string>()
const autofocus = (node: HTMLElement) => {
node.focus();
}
const handleSave = (e: Event) => {
e.preventDefault()
item.models = selectedModels.join(";")
onSave(item)
}
const handleCancel = (e: Event) => {
e.preventDefault()
onCancel()
}
const fetchModels = (e: Event) => {
e.preventDefault()
modelsErrors.clear()
providerApi.getAvailableModels(item)
.then((data) => {
availableModels = data.map((it)=>{
return { value: it, selected: selectedModels.includes(it)}
});
})
.catch((err) => { modelsErrors.set("models",err) })
}
const handleModelChange = () => {
selectedModels = availableModels.filter((it)=>it.selected).map((it)=>it.value)
}
</script>
<div>
<div class="formgroup my-2">
<form onsubmit={handleSave} onchange={()=>{errors.clear()}}>
<div class="formelement">
<label for="name">Nom</label>
<input
id="name"
type="text"
bind:value={item.name}
class="textinput"
placeholder="nom affiché"
use:autofocus
/>
<FormError key="name" errors={errors} />
</div>
<div class="formelement">
<label for="type" class="block my-1 flex-1">Type</label>
<Select items={providerTypes}
valueMode="id" itemId="id" label="text"
bind:value={item.type}
/>
<FormError key="type" errors={errors} />
</div>
<div class="formelement">
<label for="baseUrl">URL</label>
<input
id="baseUrl"
type="text"
bind:value={item.baseUrl}
class="textinput"
placeholder="http://127.0.0.1:8080/v1"
autocomplete="off"
/>
<FormError key="baseUrl" errors={errors} />
</div>
<div class="formelement">
<label for="apiKey">Clé API</label>
<input
id="apiKey"
type="password"
bind:value={item.apiKey}
class="textinput"
placeholder="clé API (optionnel)"
autocomplete="new-password"
/>
<FormError key="apiKey" errors={errors} />
</div>
<div class="formelement">
<label for="apiKey">Models</label>
<button class="bt small" onclick={fetchModels}>
Afficher les modèles disponibles
</button>
<ol class="ml-2">
{#each availableModels as model (model.value)}
{#if model.value}
<li>
<label>
<input type="checkbox" bind:checked={model.selected} onchange={handleModelChange}/>
{model.value}
</label>
</li>
{/if}
{/each}
</ol>
<div><FormError key="models" errors={modelsErrors} /></div>
</div>
<div class="text-right">
<button type="button" class="bt" onclick={handleCancel}>Annuler</button>
<button type="submit" class="bt mx-4">Enregistrer</button>
</div>
</form>
</div>
</div>
+34 -18
View File
@@ -8,35 +8,45 @@ html {
@apply h-full; @apply h-full;
} }
body { body {
@apply h-full bg-stone-100 text-stone-900 dark:bg-stone-900 dark:text-stone-200; @apply h-full bg-stone-100 text-stone-950 dark:bg-stone-950 dark:text-stone-100;
} }
label { .formelement > label {
@apply text-xs text-stone-600 uppercase dark:text-stone-400; @apply text-xs text-stone-600 uppercase dark:text-stone-400;
} }
hr { hr {
@apply border-stone-200 dark:border-stone-800; @apply border-stone-200 dark:border-stone-800;
} }
h2 { h2 {
@apply mb-4 font-bold; @apply mb-4 text-base font-bold;
}
.container {
@apply mx-auto max-w-3xl;
} }
.pointer { .pointer {
@apply cursor-pointer; @apply cursor-pointer;
} }
.bt { .bt {
appearance: button;
@apply cursor-pointer rounded bg-stone-300 px-4 py-1 font-bold hover:bg-stone-200 dark:bg-stone-800 dark:hover:bg-stone-700; @apply cursor-pointer rounded bg-stone-300 px-4 py-1 font-bold hover:bg-stone-200 dark:bg-stone-800 dark:hover:bg-stone-700;
&.small {
@apply px-2 py-1;
}
} }
.menuitem { .menuitem {
@apply text-stone-800 text-stone-950 dark:text-stone-300 hover:dark:text-stone-50; @apply text-stone-950 dark:text-stone-300 hover:dark:text-stone-50;
} }
.header { .header {
@apply p-2 text-lg; @apply py-2 text-lg font-bold;
/*
h1 { h1 {
@apply rounded bg-stone-200 px-2 font-bold text-sky-600 dark:bg-stone-800 dark:text-sky-200; @apply rounded bg-stone-200 px-2 font-bold uppercase dark:bg-stone-800;
} }
*/
&.settings { &.settings {
h1 { h1 {
@@ -53,10 +63,6 @@ h2 {
@apply text-sky-500; @apply text-sky-500;
} }
.formgroup {
@apply rounded border border-stone-300 bg-stone-100 p-2 dark:border-stone-700 dark:bg-stone-900;
}
#outer { #outer {
@apply flex h-full flex-row; @apply flex h-full flex-row;
} }
@@ -94,7 +100,7 @@ h2 {
@apply ml-10; @apply ml-10;
} }
#sidebar { #sidebar {
@apply fixed left-0 h-full w-full bg-stone-100 dark:bg-stone-900; @apply fixed left-0 h-full w-full bg-stone-100 dark:bg-stone-950;
} }
.mobileMenuClosed { .mobileMenuClosed {
#sidebar { #sidebar {
@@ -103,14 +109,24 @@ h2 {
} }
} }
/* svelte select */ /* form */
.textinput { .formgroup {
@apply h-8 w-full rounded border-stone-700 bg-white px-2 py-1 text-stone-900 dark:bg-stone-800 dark:text-stone-100; @apply rounded border border-stone-300 bg-stone-100 p-2 text-sm dark:border-stone-700 dark:bg-stone-900;
} }
.formelement {
@apply mb-3;
}
.textinput {
@apply h-8 w-full rounded border-stone-700 bg-stone-50 px-2 py-1 text-sm text-stone-900 dark:bg-stone-950 dark:text-stone-100;
}
/* svelte select */
.svelte-select { .svelte-select {
@apply relative flex h-8 w-full border-none text-stone-900 dark:bg-stone-800 dark:text-stone-100; @apply relative flex h-8 w-full border-none text-stone-900 dark:bg-stone-950 dark:text-stone-100;
} }
.svelte-select .value-container { .svelte-select .value-container {
@@ -187,7 +203,7 @@ h2 {
} }
.svelte-select-list .list-group-title { .svelte-select-list .list-group-title {
@apply h-8 cursor-default px-5 text-sm leading-10 font-medium overflow-ellipsis whitespace-nowrap text-gray-400 uppercase; @apply h-8 cursor-default px-5 text-sm leading-10 font-medium text-ellipsis whitespace-nowrap text-gray-400 uppercase;
} }
.svelte-select-list .empty { .svelte-select-list .empty {
@@ -195,7 +211,7 @@ h2 {
} }
.svelte-select-item { .svelte-select-item {
@apply h-8 cursor-default overflow-hidden px-5 leading-10 overflow-ellipsis whitespace-nowrap text-gray-800; @apply h-8 cursor-default overflow-hidden px-5 leading-10 text-ellipsis whitespace-nowrap text-gray-800;
} }
.item.group-item { .item.group-item {
@@ -223,7 +239,7 @@ h2 {
} }
.multi-item { .multi-item {
@apply mt-1 mr-1 flex h-8 max-w-full cursor-default items-center overflow-hidden rounded-sm border border-gray-200 bg-gray-100 pr-1 pl-1 leading-8 overflow-ellipsis whitespace-nowrap; @apply mt-1 mr-1 flex h-8 max-w-full cursor-default items-center overflow-hidden rounded-sm border border-gray-200 bg-gray-100 pr-1 pl-1 leading-8 text-ellipsis whitespace-nowrap;
} }
.multi-item.disabled { .multi-item.disabled {
@@ -119,6 +119,18 @@
"css": "cancel", "css": "cancel",
"code": 59401, "code": 59401,
"src": "fontawesome" "src": "fontawesome"
},
{
"uid": "44e04715aecbca7f266a17d5a7863c68",
"css": "plus",
"code": 59402,
"src": "fontawesome"
},
{
"uid": "f48ae54adfb27d8ada53d0fd9e34ee10",
"css": "trash-empty",
"code": 59403,
"src": "fontawesome"
} }
] ]
} }
@@ -9,6 +9,8 @@
.icon-logout:before { content: '\e807'; } /* '' */ .icon-logout:before { content: '\e807'; } /* '' */
.icon-left-big:before { content: '\e808'; } /* '' */ .icon-left-big:before { content: '\e808'; } /* '' */
.icon-cancel:before { content: '\e809'; } /* '' */ .icon-cancel:before { content: '\e809'; } /* '' */
.icon-plus:before { content: '\e80a'; } /* '' */
.icon-trash-empty:before { content: '\e80b'; } /* '' */
.icon-facebook:before { content: '\f09a'; } /* '' */ .icon-facebook:before { content: '\f09a'; } /* '' */
.icon-tasks:before { content: '\f0ae'; } /* '' */ .icon-tasks:before { content: '\f0ae'; } /* '' */
.icon-menu:before { content: '\f0c9'; } /* '' */ .icon-menu:before { content: '\f0c9'; } /* '' */
@@ -1,15 +1,15 @@
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
src: url('../font/fontello.eot?82712141'); src: url('../font/fontello.eot?20532310');
src: url('../font/fontello.eot?82712141#iefix') format('embedded-opentype'), src: url('../font/fontello.eot?20532310#iefix') format('embedded-opentype'),
url('../font/fontello.svg?82712141#fontello') format('svg'); url('../font/fontello.svg?20532310#fontello') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
src: url('data:application/octet-stream;base64,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 truncated src: url('data:application/octet-stream;base64,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 truncated
url('data:application/octet-stream;base64,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 truncated url('data:application/octet-stream;base64,AAEAAAAPAIAAAwBwR1NVQiCLJXoAAAD8AAAAVE9TLzI+I1PFAAABUAAAAGBjbWFwb2carQAAAbAAAALQY3Z0IAAAAAAAAB1gAAAADmZwZ21iLvl6AAAdcAAADgxnYXNwAAAAEAAAHVgAAAAIZ2x5ZnrldDUAAASAAAAUFGhlYWQxnQn3AAAYlAAAADZoaGVhCFoEgwAAGMwAAAAkaG10eEz9//0AABjwAAAAWGxvY2E8eDhiAAAZSAAAAC5tYXhwAU4OxwAAGXgAAAAgbmFtZc2dHR4AABmYAAACzXBvc3S/M3V0AAAcaAAAAO5wcmVwfrY7tgAAK3wAAACcAAEAAAAKADAAPgACREZMVAAObGF0bgAaAAQAAAAAAAAAAQAAAAQAAAAAAAAAAQAAAAFsaWdhAAgAAAABAAAAAQAEAAQAAAABAAgAAQAGAAAAAQAAAAQDgAGQAAUAAAJ6ArwAAACMAnoCvAAAAeAAMQECAAACAAUDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFBmRWQAwOgA8doDUv9qAFoDrACWAAAAAQAAAAAAAAAAAAAAAAACAAAABQAAAAMAAAAsAAAABAAAAcQAAQAAAAAAvgADAAEAAAAsAAMACgAAAcQABACSAAAAFgAQAAMABugL8JrwrvDJ8RPxefGd8aDx2PHa//8AAOgA8JrwrvDJ8RPxefGd8aDx2PHa//8AAAAAAAAAAAAAAAAAAAAAAAAAAAABABYALAAsACwALAAsACwALAAsACwAAAABAAIAAwAEAAUABgAHAAgACQAKAAsADAANAA4ADwAQABEAEgATABQAFQAAAQYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAABDAAAAAAAAAAVAADoAAAA6AAAAAABAADoAQAA6AEAAAACAADoAgAA6AIAAAADAADoAwAA6AMAAAAEAADoBAAA6AQAAAAFAADoBQAA6AUAAAAGAADoBgAA6AYAAAAHAADoBwAA6AcAAAAIAADoCAAA6AgAAAAJAADoCQAA6AkAAAAKAADoCgAA6AoAAAALAADoCwAA6AsAAAAMAADwmgAA8JoAAAANAADwrgAA8K4AAAAOAADwyQAA8MkAAAAPAADxEwAA8RMAAAAQAADxeQAA8XkAAAARAADxnQAA8Z0AAAASAADxoAAA8aAAAAATAADx2AAA8dgAAAAUAADx2gAA8doAAAAVAAIAAP+wA+gCwwAlAEsAP0A8SRwCAAE/AQMAKQECAwNMCgEDAUsyAQJJAAEAAYUAAAMAhQADAgIDWQADAwJhAAIDAlFCQD48IyIjBAYXKwEUDgEjIicGBwYHIyImNSY0NjU/AjYHNz4CNy4BJzQ+ATIeARcUBgceAR8BFh8DFAcOAScmJyYnBiMiJxYzMjY3PgEnNCceAQMSarRrMDJGVRUbAgYMAQIBBAMDARwFDg4ERU4BarTWtGrWUEQFDAgbCQQFBAMBAgoHHBRWRjIwl3AgEVqkQkVMAQ1IVAGlTYRMCTEXBQQKBwEEBAEDBgMDAR4FGBIQKHRDToRMTITcQ3YnDhYKIQsDBQYKAQIICgEEBRcxCUoDMi80hkorKid4AAb///9qBC8DUgARADIAOwBEAFYAXwBvQGxPDgIDAgFMEQEJCwmFAAsIC4UQAQgCCIUPAQIDAoUHAQUAAQAFAYAMCgIBBgABBn4ABgQABgR+AAQEhA4BAwAAA1kOAQMDAGENAQADAFFeXVpZVlRSUEtKSUdDQj8+OjkZFRQZNyMTIRASBh8rAQYHIyImNzQzMh4BNzI3BhUUARQGIyEiJic0PgUzMh4CPgE/ATY3Mh4EFwEUBiImNDYyFgEUBi4BPgIWBRQGJyMmJzY1NCcWMzI+ARcyJxQGIiY0NjIWAUtaOkstQAFFBCpCISYlAwKDUkP+GERQAQQMECAmOiEGJC5IUEYZKRAIIjgmIBAOAf3GVHZUVHZUAYl+sIACfLR6AUM+Lks5Wi0DJSUhRCgERUdUdlRUdlQBXgNELCzFFhoBDRUQTv5bQk5OQh44Qjg0JhYYHBoCFhAaCgIWJjQ4QhwCjztUVHZUVP7vWX4CerZ4BoTTKy4BRANBThAVDRgYAY87VFR2VFQAAwAA/3YDoAMLAAgAFAAuADNAMCYBBAMoJxIDAgQAAQEAA0wAAwQDhQAEAgSFAAIAAoUAAAEAhQABAXYcIy0YEgUGGys3NCYOAh4BNiUBBiIvASY0NwEeASUUBw4BJyImNDY3MhYXFhQPARUXNj8BNjIW1hQeFAIYGhgBZv6DFToWOxUVAXwWVAGZDRuCT2iSkmggRhkJCaNsAipLIQ8KHQ4WAhIgEgQa9v6DFBQ9FDsWAXw3VN0WJUteAZLQkAIUEAYSB159PAIZLRQKAAAAAAIAAP+xA1oDCwAIAGoARUBCZVlMQQQABDsKAgEANCgbEAQDAQNMAAUEBYUGAQQABIUAAAEAhQABAwGFAAMCA4UAAgJ2XFtTUUlIKyoiIBMSBwYYKwE0JiIOARYyNiUVFAYPAQYHFhcWFAcOASciLwEGBwYHBisBIiY1JyYnBwYiJyYnJjQ3PgE3Ji8BLgEnNTQ2PwE2NyYnJjQ3PgEzMh8BNjc2NzY7ATIWHwEWFzc2MhcWFxYUBw4BBxYfAR4BAjtSeFICVnRWARwIB2gKCxMoBgUPUA0HB00ZGgkHBBB8CAwQGxdPBhAGRhYEBQgoCg8IZgcIAQoFaAgOFyUGBQ9QDQcITRgaCQgDEXwHDAEPHBdPBQ8HSBQEBAkoCg8IZgcKAV47VFR2VFR4fAcMARAeFRsyBg4GFVABBTwNCEwcEAoHZwkMPAUGQB4FDgYMMg8cGw8BDAd8BwwBEBkaIC0HDAcUUAU8DQhMHBAKB2cJCzsFBUMcBQ4GDDIPHBoQAQwAAAACAAD/sQLKAwwAFQAeACVAIgAFAQWFAwEBBAGFAAQCBIUAAgAChQAAAHYTFxERFzIGBhwrJRQGIyEiJjU0PgMXFjI3Mh4DAxQGIi4BNh4BAspGMf4kMUYKGCo+LUnKSipCJhwIj3y0egSCrIRFPFhYPDBUVjwoAUhIJj5UVgHAWH5+sIACfAAABQAA//kD5AMLAAYADwA5AD4ASAEHQBVAPjsQAwIBBwAENAEBAAJMQQEEAUtLsApQWEAwAAcDBAMHBIAAAAQBAQByAAMABAADBGcIAQEABgUBBmgABQICBVcABQUCXwACBQJPG0uwC1BYQCkAAAQBAQByBwEDAAQAAwRnCAEBAAYFAQZoAAUCAgVXAAUFAl8AAgUCTxtLsBdQWEAwAAcDBAMHBIAAAAQBAQByAAMABAADBGcIAQEABgUBBmgABQICBVcABQUCXwACBQJPG0AxAAcDBAMHBIAAAAQBBAABgAADAAQAAwRnCAEBAAYFAQZoAAUCAgVXAAUFAl8AAgUCT1lZWUAWAABEQz08MS4pJh4bFhMABgAGFAkGFyslNycHFTMVASYPAQYWPwE2ExUUBiMhIiY1ETQ2NyEyFx4BDwEGJyYjISIGBxEUFhchMjY9ATQ/ATYWAxcBIzUBByc3NjIfARYUAfBAVUA1ARUJCcQJEgnECSReQ/4wQ15eQwHQIx4JAwcbCAoNDP4wJTQBNiQB0CU0BSQIGDeh/omhAm8zoTMQLBBVEL1BVUEfNgGSCQnECRIJxAn+vmpDXl5DAdBCXgEOBBMGHAgEAzQl/jAlNAE2JEYHBSQICAGPoP6JoAEuNKE0Dw9VECwAAQAA/4gDNQLtAB4AJEAhAAMCA4UAAAEAhgACAQECVwACAgFfAAECAU8WJSYUBAYaKwEUBwEGIi8BJjQ/ASEiJj0BNDYXIScmND8BNjIXARYDNRT+lRY6FSoWFqP+dx0kJB0BiaMWFioVOhYBaxQBOh4U/pQUFCoVPBWjKh5HHioBpRQ8FCoVFf6VFAACAAD/+QNrAsMAJwBAAEJAPxQBAgEBTAAGAgUCBgWAAAUDAgUDfgAEAwADBACAAAEAAgYBAmcAAwQAA1cAAwMAXwAAAwBPFiMZJSolJwcGHSslFBYPAQ4BByMiJjURNDY7ATIWFRcWDwEOAScjIgYHERQWFzMyHgIBFAcBBiImPQEjIiY9ATQ2NzM1NDYWFwEWAWUCAQIBCAiyQ15eQ7IICgEBAQIBCAiyJTQBNiS0BgIGAgIGC/7RCxwW+g4WFg76FhwLAS8LLgISBQ4JBAFeQwGIQ14KCAsJBg0HCAE0Jv54JTQBBAIIASwOC/7QChQPoRYO1g8UAaEOFgIJ/tAKAAAAAAEAAP+IA1kC7QAdACRAIQACAwKFAAEAAYYAAwAAA1cAAwMAXwAAAwBPJhcWIwQGGisBFRQGIyEXFhQPAQYiJwEmNDcBNjIfARYUDwEhMhYDWSQd/nekFRUqFTsV/pQUFAFsFToWKhUVpAGJHSQBXkceKqQUPBQrFBQBbBU6FgFrFRUpFjoWpCgAAAAAAQAA/+8C1AKGACQAHkAbIhkQBwQAAgFMAwECAAKFAQEAAHYUHBQUBAYaKyUUDwEGIi8BBwYiLwEmND8BJyY0PwE2Mh8BNzYyHwEWFA8BFxYC1A9MECwQpKQQLBBMEBCkpBAQTBAsEKSkECwQTA8PpKQPcBLine truncated
} }
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */ /* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */ /* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
@@ -17,7 +17,7 @@
@media screen and (-webkit-min-device-pixel-ratio:0) { @media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
src: url('../font/fontello.svg?82712141#fontello') format('svg'); src: url('../font/fontello.svg?20532310#fontello') format('svg');
} }
} }
*/ */
@@ -67,6 +67,8 @@
.icon-logout:before { content: '\e807'; } /* '' */ .icon-logout:before { content: '\e807'; } /* '' */
.icon-left-big:before { content: '\e808'; } /* '' */ .icon-left-big:before { content: '\e808'; } /* '' */
.icon-cancel:before { content: '\e809'; } /* '' */ .icon-cancel:before { content: '\e809'; } /* '' */
.icon-plus:before { content: '\e80a'; } /* '' */
.icon-trash-empty:before { content: '\e80b'; } /* '' */
.icon-facebook:before { content: '\f09a'; } /* '' */ .icon-facebook:before { content: '\f09a'; } /* '' */
.icon-tasks:before { content: '\f0ae'; } /* '' */ .icon-tasks:before { content: '\f0ae'; } /* '' */
.icon-menu:before { content: '\f0c9'; } /* '' */ .icon-menu:before { content: '\f0c9'; } /* '' */
@@ -9,6 +9,8 @@
.icon-logout { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe807;&nbsp;'); } .icon-logout { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe807;&nbsp;'); }
.icon-left-big { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe808;&nbsp;'); } .icon-left-big { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe808;&nbsp;'); }
.icon-cancel { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe809;&nbsp;'); } .icon-cancel { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe809;&nbsp;'); }
.icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe80a;&nbsp;'); }
.icon-trash-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe80b;&nbsp;'); }
.icon-facebook { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf09a;&nbsp;'); } .icon-facebook { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf09a;&nbsp;'); }
.icon-tasks { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0ae;&nbsp;'); } .icon-tasks { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0ae;&nbsp;'); }
.icon-menu { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0c9;&nbsp;'); } .icon-menu { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0c9;&nbsp;'); }
@@ -20,6 +20,8 @@
.icon-logout { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe807;&nbsp;'); } .icon-logout { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe807;&nbsp;'); }
.icon-left-big { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe808;&nbsp;'); } .icon-left-big { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe808;&nbsp;'); }
.icon-cancel { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe809;&nbsp;'); } .icon-cancel { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe809;&nbsp;'); }
.icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe80a;&nbsp;'); }
.icon-trash-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe80b;&nbsp;'); }
.icon-facebook { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf09a;&nbsp;'); } .icon-facebook { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf09a;&nbsp;'); }
.icon-tasks { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0ae;&nbsp;'); } .icon-tasks { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0ae;&nbsp;'); }
.icon-menu { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0c9;&nbsp;'); } .icon-menu { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf0c9;&nbsp;'); }
+9 -7
View File
@@ -1,11 +1,11 @@
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
src: url('../font/fontello.eot?90952797'); src: url('../font/fontello.eot?1806785');
src: url('../font/fontello.eot?90952797#iefix') format('embedded-opentype'), src: url('../font/fontello.eot?1806785#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?90952797') format('woff2'), url('../font/fontello.woff2?1806785') format('woff2'),
url('../font/fontello.woff?90952797') format('woff'), url('../font/fontello.woff?1806785') format('woff'),
url('../font/fontello.ttf?90952797') format('truetype'), url('../font/fontello.ttf?1806785') format('truetype'),
url('../font/fontello.svg?90952797#fontello') format('svg'); url('../font/fontello.svg?1806785#fontello') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@@ -15,7 +15,7 @@
@media screen and (-webkit-min-device-pixel-ratio:0) { @media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
src: url('../font/fontello.svg?90952797#fontello') format('svg'); src: url('../font/fontello.svg?1806785#fontello') format('svg');
} }
} }
*/ */
@@ -64,6 +64,8 @@
.icon-logout:before { content: '\e807'; } /* '' */ .icon-logout:before { content: '\e807'; } /* '' */
.icon-left-big:before { content: '\e808'; } /* '' */ .icon-left-big:before { content: '\e808'; } /* '' */
.icon-cancel:before { content: '\e809'; } /* '' */ .icon-cancel:before { content: '\e809'; } /* '' */
.icon-plus:before { content: '\e80a'; } /* '' */
.icon-trash-empty:before { content: '\e80b'; } /* '' */
.icon-facebook:before { content: '\f09a'; } /* '' */ .icon-facebook:before { content: '\f09a'; } /* '' */
.icon-tasks:before { content: '\f0ae'; } /* '' */ .icon-tasks:before { content: '\f0ae'; } /* '' */
.icon-menu:before { content: '\f0c9'; } /* '' */ .icon-menu:before { content: '\f0c9'; } /* '' */
+17 -9
View File
@@ -146,11 +146,11 @@
} }
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
src: url('./font/fontello.eot?42653923'); src: url('./font/fontello.eot?13627011');
src: url('./font/fontello.eot?42653923#iefix') format('embedded-opentype'), src: url('./font/fontello.eot?13627011#iefix') format('embedded-opentype'),
url('./font/fontello.woff?42653923') format('woff'), url('./font/fontello.woff?13627011') format('woff'),
url('./font/fontello.ttf?42653923') format('truetype'), url('./font/fontello.ttf?13627011') format('truetype'),
url('./font/fontello.svg?42653923#fontello') format('svg'); url('./font/fontello.svg?13627011#fontello') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@@ -245,34 +245,42 @@
<div class="span3" title="Code: 0xe809"> <div class="span3" title="Code: 0xe809">
<i class="demo-icon icon-cancel">&#xe809;</i> <span class="i-name">icon-cancel</span><span class="i-code">0xe809</span> <i class="demo-icon icon-cancel">&#xe809;</i> <span class="i-name">icon-cancel</span><span class="i-code">0xe809</span>
</div> </div>
<div class="span3" title="Code: 0xe80a">
<i class="demo-icon icon-plus">&#xe80a;</i> <span class="i-name">icon-plus</span><span class="i-code">0xe80a</span>
</div>
<div class="span3" title="Code: 0xe80b">
<i class="demo-icon icon-trash-empty">&#xe80b;</i> <span class="i-name">icon-trash-empty</span><span class="i-code">0xe80b</span>
</div>
</div>
<div class="row">
<div class="span3" title="Code: 0xf09a"> <div class="span3" title="Code: 0xf09a">
<i class="demo-icon icon-facebook">&#xf09a;</i> <span class="i-name">icon-facebook</span><span class="i-code">0xf09a</span> <i class="demo-icon icon-facebook">&#xf09a;</i> <span class="i-name">icon-facebook</span><span class="i-code">0xf09a</span>
</div> </div>
<div class="span3" title="Code: 0xf0ae"> <div class="span3" title="Code: 0xf0ae">
<i class="demo-icon icon-tasks">&#xf0ae;</i> <span class="i-name">icon-tasks</span><span class="i-code">0xf0ae</span> <i class="demo-icon icon-tasks">&#xf0ae;</i> <span class="i-name">icon-tasks</span><span class="i-code">0xf0ae</span>
</div> </div>
</div>
<div class="row">
<div class="span3" title="Code: 0xf0c9"> <div class="span3" title="Code: 0xf0c9">
<i class="demo-icon icon-menu">&#xf0c9;</i> <span class="i-name">icon-menu</span><span class="i-code">0xf0c9</span> <i class="demo-icon icon-menu">&#xf0c9;</i> <span class="i-name">icon-menu</span><span class="i-code">0xf0c9</span>
</div> </div>
<div class="span3" title="Code: 0xf113"> <div class="span3" title="Code: 0xf113">
<i class="demo-icon icon-github">&#xf113;</i> <span class="i-name">icon-github</span><span class="i-code">0xf113</span> <i class="demo-icon icon-github">&#xf113;</i> <span class="i-name">icon-github</span><span class="i-code">0xf113</span>
</div> </div>
</div>
<div class="row">
<div class="span3" title="Code: 0xf179"> <div class="span3" title="Code: 0xf179">
<i class="demo-icon icon-apple">&#xf179;</i> <span class="i-name">icon-apple</span><span class="i-code">0xf179</span> <i class="demo-icon icon-apple">&#xf179;</i> <span class="i-name">icon-apple</span><span class="i-code">0xf179</span>
</div> </div>
<div class="span3" title="Code: 0xf19d"> <div class="span3" title="Code: 0xf19d">
<i class="demo-icon icon-graduation-cap">&#xf19d;</i> <span class="i-name">icon-graduation-cap</span><span class="i-code">0xf19d</span> <i class="demo-icon icon-graduation-cap">&#xf19d;</i> <span class="i-name">icon-graduation-cap</span><span class="i-code">0xf19d</span>
</div> </div>
</div>
<div class="row">
<div class="span3" title="Code: 0xf1a0"> <div class="span3" title="Code: 0xf1a0">
<i class="demo-icon icon-google">&#xf1a0;</i> <span class="i-name">icon-google</span><span class="i-code">0xf1a0</span> <i class="demo-icon icon-google">&#xf1a0;</i> <span class="i-name">icon-google</span><span class="i-code">0xf1a0</span>
</div> </div>
<div class="span3" title="Code: 0xf1d8"> <div class="span3" title="Code: 0xf1d8">
<i class="demo-icon icon-paper-plane">&#xf1d8;</i> <span class="i-name">icon-paper-plane</span><span class="i-code">0xf1d8</span> <i class="demo-icon icon-paper-plane">&#xf1d8;</i> <span class="i-name">icon-paper-plane</span><span class="i-code">0xf1d8</span>
</div> </div>
</div>
<div class="row">
<div class="span3" title="Code: 0xf1da"> <div class="span3" title="Code: 0xf1da">
<i class="demo-icon icon-history">&#xf1da;</i> <span class="i-name">icon-history</span><span class="i-code">0xf1da</span> <i class="demo-icon icon-history">&#xf1da;</i> <span class="i-name">icon-history</span><span class="i-code">0xf1da</span>
</div> </div>
Binary file not shown.
@@ -26,6 +26,10 @@
<glyph glyph-name="cancel" unicode="&#xe809;" d="M724 112q0-22-15-38l-76-76q-16-15-38-15t-38 15l-164 165-164-165q-16-15-38-15t-38 15l-76 76q-16 16-16 38t16 38l164 164-164 164q-16 16-16 38t16 38l76 76q16 16 38 16t38-16l164-164 164 164q16 16 38 16t38-16l76-76q15-15 15-38t-15-38l-164-164 164-164q15-15 15-38z" horiz-adv-x="785.7" /> <glyph glyph-name="cancel" unicode="&#xe809;" d="M724 112q0-22-15-38l-76-76q-16-15-38-15t-38 15l-164 165-164-165q-16-15-38-15t-38 15l-76 76q-16 16-16 38t16 38l164 164-164 164q-16 16-16 38t16 38l76 76q16 16 38 16t38-16l164-164 164 164q16 16 38 16t38-16l76-76q15-15 15-38t-15-38l-164-164 164-164q15-15 15-38z" horiz-adv-x="785.7" />
<glyph glyph-name="plus" unicode="&#xe80a;" d="M786 439v-107q0-22-16-38t-38-15h-232v-233q0-22-16-37t-38-16h-107q-22 0-38 16t-15 37v233h-232q-23 0-38 15t-16 38v107q0 23 16 38t38 16h232v232q0 22 15 38t38 16h107q23 0 38-16t16-38v-232h232q23 0 38-16t16-38z" horiz-adv-x="785.7" />
<glyph glyph-name="trash-empty" unicode="&#xe80b;" d="M286 439v-321q0-8-5-13t-13-5h-36q-8 0-13 5t-5 13v321q0 8 5 13t13 5h36q8 0 13-5t5-13z m143 0v-321q0-8-5-13t-13-5h-36q-8 0-13 5t-5 13v321q0 8 5 13t13 5h36q8 0 13-5t5-13z m142 0v-321q0-8-5-13t-12-5h-36q-8 0-13 5t-5 13v321q0 8 5 13t13 5h36q7 0 12-5t5-13z m72-404v529h-500v-529q0-12 4-22t8-15 6-5h464q2 0 6 5t8 15 4 22z m-375 601h250l-27 65q-4 5-9 6h-177q-6-1-10-6z m518-18v-36q0-8-5-13t-13-5h-54v-529q0-46-26-80t-63-34h-464q-37 0-63 33t-27 79v531h-53q-8 0-13 5t-5 13v36q0 8 5 13t13 5h172l39 93q9 21 31 35t44 15h178q23 0 44-15t30-35l39-93h173q8 0 13-5t5-13z" horiz-adv-x="785.7" />
<glyph glyph-name="facebook" unicode="&#xf09a;" d="M535 843v-147h-87q-48 0-65-20t-17-60v-106h164l-22-165h-142v-424h-171v424h-142v165h142v122q0 104 58 161t155 57q82 0 127-7z" horiz-adv-x="571.4" /> <glyph glyph-name="facebook" unicode="&#xf09a;" d="M535 843v-147h-87q-48 0-65-20t-17-60v-106h164l-22-165h-142v-424h-171v424h-142v165h142v122q0 104 58 161t155 57q82 0 127-7z" horiz-adv-x="571.4" />
<glyph glyph-name="tasks" unicode="&#xf0ae;" d="M571 64h358v72h-358v-72z m-214 286h572v71h-572v-71z m357 286h215v71h-215v-71z m286-465v-142q0-15-11-25t-25-11h-928q-15 0-25 11t-11 25v142q0 15 11 26t25 10h928q15 0 25-10t11-26z m0 286v-143q0-14-11-25t-25-10h-928q-15 0-25 10t-11 25v143q0 15 11 25t25 11h928q15 0 25-11t11-25z m0 286v-143q0-14-11-25t-25-11h-928q-15 0-25 11t-11 25v143q0 14 11 25t25 11h928q15 0 25-11t11-25z" horiz-adv-x="1000" /> <glyph glyph-name="tasks" unicode="&#xf0ae;" d="M571 64h358v72h-358v-72z m-214 286h572v71h-572v-71z m357 286h215v71h-215v-71z m286-465v-142q0-15-11-25t-25-11h-928q-15 0-25 11t-11 25v142q0 15 11 26t25 10h928q15 0 25-10t11-26z m0 286v-143q0-14-11-25t-25-10h-928q-15 0-25 10t-11 25v143q0 15 11 25t25 11h928q15 0 25-11t11-25z m0 286v-143q0-14-11-25t-25-11h-928q-15 0-25 11t-11 25v143q0 14 11 25t25 11h928q15 0 25-11t11-25z" horiz-adv-x="1000" />

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.